제목 : Spring Container, 다른 관점으로 "직접 제작 해보기"
부제목 : 오로지 Java 와 "내장 API" 만 사용해서 만들기
모든 글을 마무리 하고 나서 작성하는 문단
Java 라는 언어를 전제로, 어떠한 언어든지 상관없는 "메타데이터" 라는 주제로 가벼운 프레임워크를 제작했습니다.
초급 수준의 입문자라면, 언어의 문법과 콜스택 메모리, 힙 메모리에 대한 이해도가 필요하기 때문에,
이 페이지를 "북마크" 하신 후 천천~히 읽으시는 것을 매우 추천합니다.
중급 ~ 그 이상 수준의 Java 및 컴퓨팅 이해도를 가지신 분이시라면,
다른 시각에서 만들게 된 Light Framework 를 이해하게 만들기 위해 최대한 풀어서 설명했습니다.
이 때문에 글이 길어지기도 했습니다.
깃허브 링크 주소
후반에 나올 내용이 이해되지 않을 때, 코드와 함께 본다면 이해가 쉬울 수 있습니다.
그러나, "코드를 먼저 보는 것" 은 추천하지 않습니다.
대부분의 어려운 기능에 Comment 를 달아놓았기 때문에, 이해가 되실 겁니다.
이 글을 작성하는 이유
단순히 특정 하나의 언어만을 사용하여 알고리즘 논리를 단련하거나,
내가 원하는 형태의 조그마한 정보 덩어리(객체) 를 만들 수 있다.
어떠한 요건 하에서라도, 조그마한 로직을 구현 할 때,
우리는 프로그램이 실행되고 나서 종료되기까지의 데이터 생명주기를 어떤 형태던 작성하게 된다.
아마 이 글을 읽는 분이라면, Framework, 라는 단어를 거의 무조건 알 것이라고 생각한다.
프레임워크란 무엇인가? 정의를 내려본다면,
프레임워크는 해당 프로그램, 혹은 코드 템플릿 그대로 완성되어 있다.
그러나, 이 프레임워크를 사용하는 개발자 혹은 사용자의 시각에서는
당연히 이 프로그램은 원하는 목적에 알맞게 완성되어 있지 않다.
개발자, 사용자가 원하는 다양한 기능을 탑재하되, 이미 흐름(Flow) 는 완성되어 있다.
그리고 대체로 프레임워크의 내부에 작성한 특정 논리가 사용자가 생각 한 대로 움직이지 않고,
사용하는 프레임워크가 내부에 "비즈니스 로직" 이라는 개념으로 장착한다.
flowchart TB
Spring("Spring - 디폴트 세팅부터 실행까지 이미 완성")
My-Info("내 비즈니스 로직 - 내가 원하는 목적을 이루기 위해 코드를 삽입")
Custom-Spring("Spring-커스텀화 <br/> 내 목적에 알맞은 Spring 이 됨.")
Spring --> Custom-Spring
My-Info --> Custom-Spring
이러한 기능 덕분에 사용자는 요청과 응답을 위한 데이터 처리 과정에만 집중 할 수 있다는 점이
프레임워크 라고 말할 수 있다고 생각한다.
(React 는 스스로 라이브러리라고 말하던데, 이 전략 덕분에 수많은 웹 프레임워크에서 사용하는듯.)
세상에는 수많은 프레임워크가 존재한다.
그러나, 대표적인 프레임워크 중 하나는 무엇인가? 하면 Spring 이라고 당당히 말할 수 있다.
여기서 우리에게 스스로 질문을 던져보자. Java 를 잘한다는 가정 하에.
"왜? 우리는 Spring 을 사용하는가?"
Spring 은 너무 유명하고 많이 사용되니까, 또한 정답이라고 생각한다.
취업에도 유리하고, 역사가 깊어 개발 리소스 참조하기에 정말 좋다.
그러나, 사회적인 요인을 차단하고, Spring 을 사용하는 이유는 무엇인가를 생각 해 보자.
Spring 은 초 고도화 된 메타프로그래밍의 결과물이다.
나는 Spring 이 현대 프로그래밍 언어에 적용된 Meta 정보를 극한으로 재구성하여
개발 생산성을 올려준 프레임워크라고 생각한다.
나는 Spring 을 제외하고 제대로 사용한 프레임워크가 NestJS 이다.
NestJS 공식문서에서는 Spring 을 잘 언급하지 않고, Vue 를 계승했다고 하는데,
대부분의 로직이 Spring 과 매우매우 유사하다.
(그렇다고 Java를 사용하는 상황에서 NestJS 로 건너갈 이유는 거의 없다고 생각한다. 굳이??)
(만약 비즈니스 논리보다 웹페이지 로직에 더 치중되어 있다면, 사용할 수도 있다곤 생각한다.)
하여튼, NestJS 는 TS 에서 제공하는 메타프로그래밍 기능을 이용하여,
효과적으로 JS 로 트랜스파일링 하는 프레임워크이다.
처음에는 구현된 인터페이스와 클래스를 역으로 추적하다가 메타프로그래밍을 인식하게 되었고,
JavaScript 에서는 Decorator 라는 기능을 본격적으로 분석하고 글을 작성하여
( 글 --> 타입스크립트와 데코레이터 - TypeScript With Decorator )
어떤 방식으로 프레임워크가 메타 프로그래밍을 수행하는지 알게 되었다.
이는 단순 메타프로그래밍 면에서만 보자면 Spring 과 큰 차이는 없다. (성능 차이는 크다..)
메타프로그래밍이란?
먼저 사전적 정의를 알아보자.
"자기 자신 혹은 다른 컴퓨터 프로그램을 데이터로 취급하며 프로그램을 작성,수정하는 것을 말한다."
메타프로그래밍이라는 단어에는 언어의 범주를 넘어선 의미가 존재하는데,
이러한 의미를 제외하고, 일반적인 프로그래밍 언어의 메타프로그래밍이라는 의미에 집중하겠다.
정말 저수준의 언어, Assembly, C 와 같은 언어를 제외하고,
현대적 언어들은 메타프로그래밍을 위한 대부분의 기능을 지원한다.
예를 들어 우리가 작성한 Java 클래스, 메서드, 변수, enum, ... 등등이 있다고 가정 해 보자.
그런데, 우리가 Spring 프레임워크에서 코드를 작성 할 때,
main 기능을 하는 코드에 "코드로 직접" 선언 한 적이 있나?
그런데 어떻게 프로그램 실행 후, 내가 작성한 로직이 실행 될 수 있는 걸까?
Spring 은 자동적으로 애너테이션과 그 내부에 작성된 정보를 인식하여 재구성한다.
어떻게 이런 일이 가능할까?
클래스, 메서드, 변수 모두 메타데이터로 치환한다.
Spring 은 우리가 애너테이션으로 선언한 코드들을 모두 메타데이터로 치환한다.
클래스 이름은 무엇인지, 어떤 메서드를 가지고 있는지, 어떤 생성자를 가지는지, 등등.
메서드의 경우라면, 메서드 이름과 내부 인자, 그리고 인자의 타입과 그 수, 반환 타입을 인식한다.
변수 또한 마찬가지이다.
즉 이 현상을 요약 해 보자면,
우리가 Spring 이 시작되는 main 급에 되는 실행 코드에 굳이
따로 작성한 코드를 주입하지 않더라도, 선언한 애너테이션에 의해 인식되어
Spring 이라는 완성된 프레임워크에 개발자의 코드를 스스로 "주입"(Injection) 한다.
물론 이 "주입" 과정이 모두 같지는 않다.
- 공통점 : 프로그램의 흐름을 직접 조작하지 않으며, 맡긴다.
- 차이점 : 프로그램 외부, 내부, 혹은 과정 등등 변경하는 역할들이 다르다.
어떤 차이가 존재하는지 예를 들어 보자.
예시 1. @Component, @Service, @Controller, 등등
Spring 을 찍어서 먹어 본 사람이라도 위의 몇 가지는 분명히 본 적이 있을 것이다.
(생명주기 Scope 가 선언되지 않았다는 가정.)
일반적인 상황으로 가정했을 때, 위의 Annotation 들은
현재 Spring Project 내에서, "단 하나의 객체" 만이 존재함을 보장한다.
이는 Singleton 패턴과 큰 연관이 존재한다.
Spring 에는 Spring Container 가 존재한다. 그리고, 여기에 유일 객체들이 저장되어 있다.
그리고 이들은 단순히 요청 데이터를 어떻게 받아들이고 변형하는지,
그리고 그 데이터에 따라 어떻게 응답할지를 고려한다. ==> 비즈니스 로직
프로젝트는 매우 크고 복잡할 수 있다. 그렇다면 필요한 모든 로직 클래스를 생성해야 할까?
만약에 유일 객체가 아닌 요청 때 마다 새로 원하는 객체를 생성한다면,
복잡도에 따라 최소 수십배의 컴퓨팅 리소스를 잡아먹을 것이다.
따라서, 이들을 유일 객체로 생성 한 뒤 Spring Container 에 저장하고,
@Autowired 된 생성자 메서드나 변수에 접근하여
클래스에 필요한 객체를 "생성하지 않고", "클래스 주소를 등록" 해 준다.
위의 방식으로 리소스를 매우 효율적으로 절약 해 준다.
예시 2. @Transactional, @Entity, ..
일단 위의 2 가지 DB 와 관련된 Annotation 이 가장 차별점을 두기에 적합하다고 판단했다.
예시 1 의 애너테이션들은 모두 Spring Container, Singleton, Autowired 와 관련되어 있다.
즉, 프로그램 시작과 더불어 컴퓨팅 리소스 최적화와 의존성에 초점이 맞춰져 있다면,
@Transactional, @Entity 이 둘은 다른 영역을 담당한다.
@Transactional 은, 데이터베이스에 요청을 날렸을 때,
그 에러에 대해 어떻게 반응 할 것인지를 사용자가 직접 선언하는 것이다.
데이터베이스에 특정 쿼리를 날렸을 때, 오류가 나면, 이것을 rollback 할 것인지, commit 할 것인지.
@Entity 는 Spring 과 연결된 데이터베이스와 데이터를 요청 및 응답함에 있어
"이 형태로 주거니 받거니 하겠다" 를 선언하는 애너테이션이다.
내부에는 Primary Key 선언, 컬럼 형태, 속성 및 이름을 지정하는 애너테이션이 또 있다.
@Entity 에 속하는 클래스는 내부의 모든 정보를 메타정보로 치환당하는 입장으로서,
데이터베이스와 원활하게 소통하는 일종의 정해진 템플릿 데이터가 된다.
헷갈림을 방지하기 위한 요약을 하자면,
극도로 저 수준의 언어를 제외하고 대부분의 경우 저마다의 메타프로그래밍 지원 내장 문법과 기능이 존재하며,
Spring 프레임워크의 경우, 이 메타프로그래밍 기법들을 극한으로 사용한 프로그램 템플릿이라는 것이다.
Spring 은 우리가 따로 원활한 실행을 위한 생명주기를 작성 할 필요가 없다. 이미 완성되어 있다.
우리가 원하는 목적을 위해, 이 Spring 이라는 프레임워크에 비즈니스 논리(우리가 작성한 코드) 를
꽂아넣어 목적을 완성하는 것이다.
또한, 위에서 예시로 든 메타프로그래밍이란, 내장된 언어의 기능과 Spring 프레임워크가
내부에 기입된 Annotation 들을(@) 인식하여,
클래스, 메서드, 변수 등등 가릴 것 없이 내부의 정보들을 "메타데이터" 의 형태로 분석한다.
그리고 최적화 된 형태로 나의 코드를 Spring 프레임워크의 흐름에 넣어주는 것이다.
이것이 바로 우리가 Spring 의 main 메서드 급 되는 장소에
우리가 만든 Component, Controller, Service 등등의 클래스를 따로 선언하지 않고도,
애너테이션 만으로도 Spring 이 흩어져 있는 코드들을 인식하여 Spring Containerized 하는
결과가 나오는 것이다.
그리고, 메타프로그래밍은 예시로 들어준 기능을 제외하고 너무나도 많은 활용 사례가 존재한다.
AOP, Proxy, Intercept, Request Scoping, Isolatated, 등등..
Java 로 메타프로그래밍을 구현 해 보자.
이제 Java 로 메타프로그래밍을 수행하기 위해 먼저 2 시간정도의 조사를 실행하며 익혔는데,
"이거 제대로 설명 할 수 있을까?" 라는 생각이 들 정도로 복잡하다.
물론 끝내 작성하겠지만, 어느 정도의 선에서 마무리해야 하는지 결정해야 할 문제인 것 같다.
기능이 워낙 많아, Psuedo 코드로는 표현하면 안될 것 같고,
기능이 정확히 동작함으로 나의 설명이 정확함을 인증해야 한다.
따라서, 직접 작성한 예시 코드와 더불어 결과를 같이 보여줄 생각이다.
그리고 이 글은 내가 보여주는 코드 중 역대급 어려운 코드가 될 것 같다.
만약에 이 글을 촘촘히 읽고 계신 분이 계시다면, 저 또한 같이 공부하는 입장이니,
천천히 읽어주시면 될 것 같습니다.
반드시 알고 넘어가야 할 개념
결국 앞에서 알게 될 모든 개념은 Spring 템플릿이 수행 해 주던 일부의 기능을
우리가 직접 제작하는 것이라서, 단순히 코드를 작성하고 넘기기에는 너무 중요하다고 판단이 되었다.
확실한 건, Spring 프레임워크는 개발 도구로서, 결국에는 JVM 과 Java (타 언어가 될 수도 있겠지만)
이 두 가지에 핵심을 두고 있다는 것이다.
1. package - 패키지란?
(패키지들은 유일성을 가져야 한다.)
우리가 테스팅용으로 따로 덩그러니 xxx.java 해당 파일만 생성하지 않고
대부분의 경우 Java 프로젝트에는 특정 패키지 이름이 들어가게 된다.
예를 들어, com.example.<원하는 마지막 패키지 이름>
이러한 패키지를 우리는 Spring Initializer 사이트에서 생성한다.
나는 그러한 생각을 했었는데, 도대체 왜, 간단히 Root(.) 나, test 라는 간단한 패키지명으로
생성하지 않고,
Convention(관례)적으로 com.xxx.xxx... 으로 시작을 정하는지가 매우 궁금했다.
(물론 혼자만의 프로젝트를 생성한다면, 아주 간단한 패키지명이 가능하다.)
결과적으로 복잡한 패키지명을 가지는 이유는, Maven Central(메이븐 의존성 라이브러리 저장소)
에 특정 단체나 회사가 만든 공식 라이브러리를 올릴 때, 시작 패키지명과 제작 단체의 공식 사이트와 연계되어 있다.
이를 이용하여 Maven Central 에 존재하는 라이브러리들은 "패키지명" 을 따른다면,
이들은 각자 자동적으로 "유일한" 패키지명을 가지게 된다.
따라서 누군가 라이브러리(의존성) 을 다운로드 했을 때, 패키지명이 중첩 될 확률은 제로에 수렴한다.
(클래스를 인식하기 위해서는 패키지 이름이 필요하다.)
만약에 어떤 의존 파일이 없이 덩그러니 하나의 자바 파일이 있고,
여기서 본인 스스로의 클래스를 인식하고자 한다면, Root Package(.) 를 인식해야 한다.
애초에 패키지를 지정한 적이 없기 때문이다.
그리고 패키지의 이름은 디렉토리 계층과 동일하다.
com.codecreature.test 라는 패키지명을 가진 Testing 자바 파일이 존재한다고 가정하자.
그렇다면, 위의 상황은 다음과 같이 귀결된다.
파일을 인식하기 시작하는 시작점 + com/codecreature/test/Testing.java
보통 Spring 의 경우, <Root>src/main/java 부터 지정된 패키지명, com.codecreature.test
를 인식하며, 이 곳에 보통 @SpringBootApplication 이라는 애너테이션이 붙은 클래스가 존재한다.
이 곳에서부터 Spring 은 하위 패키지를 탐색한다. (필요시 다중 선언도 가능)
그러나 Spring 이 아닌 나만의 프로젝트를 Default Package 로 선언하게 된다면,
나중에 목적 분리를 위해 패키지를 선언했을 경우, Default Package 에 존재하는 기능들을
import ... 할 수 없다는 것이다.
2. ClassLoader 란?
우리가 사실상 Spring 에 비즈니스 논리를 쉽게 적용할 수 있는 기능은 바로,
이 클래스 로더(ClassLoader) 덕분이라고 볼 수 있다.
Java 에서 클래스 로더가 하는 역할은 우리가 만들어 놓은 객체들을 가져오는 역할을 한다.
어떻게 가져올까?
위에서 package 에 대한 정보를 길게 나열했던 이유는, 바로 이 ClassLoader 의
"객체 인식 방법"이 우리가 직관적으로 이해할 수 있는 것과 많이 다르고,
package 로서 인식하기 때문이다.
예를 들어, C 나, JavaScript 의 경우,
우리는 직관적인 디렉토리 접근법을 통해,
C:#include "..../xx.h"JS:import "..../xxx.js"orimport "..../xxx"
위와 같은 방식으로, 상대 경로를 통해 기능을 병합하거나, 불러 올 수 있다.
그러나, Java 는 다르다.
우리가 디렉토리와 "패키지" 경로는 동일하다고 했지만,
불러오는 Root 는 정해져 있다. --> Java 와 JVM 의 특성 (ClassPath)
따라서, 상대 경로로 불러오는 것이 아니라, 프로젝트에서 Root 로 정해진 디렉토리로부터,
해당 Root(ClassPath) 로부터 뻗어있는 디렉토리, 즉, 패키지를 통해 인식한다는 것이다.
(여기서 말하는 패키지란, 프로젝트 내부의 패키지만을 의미한다.)
그렇다면, 우리는 먼저 자주 사용하던 Java 의 import 를 떠올려야 한다.
만약에 com.damsoon.func 에 FuncClass.java or .class 가 있고,
com.damsoon.test 에 TestClass.java or .class 가 있다고 가정 해 보자.
(.java 는 가독성을 위함이며, 실제 런타임의 경우 .class가 정확합니다.)
import com.damsoon.func.FuncClassimport com.damsoon.test.TestClass
이러한 형식으로 2 클래스를 불러오게 된다.
먼저 파일의 구조를 살펴보자 :
# tree 명령어는 따로 설치해야 합니다. macOS 의 경우 brew 패키지 매니저 추천
➜ com tree
.
└── damsoon
├── Main.java
├── func
│ └── FuncClass.java
└── test
└── TestClass.java
4 directories, 3 files
FuncClass.java :
package com.damsoon.func;
public class FuncClass {
public FuncClass() {
System.out.println("생성자 실행 : " + getClass().getSimpleName());
}
}
TestClass.java :
package com.damsoon.test;
public class TestClass {
public TestClass () {
System.out.println("생성자 실행 : " + getClass().getSimpleName());
}
}
Main.java :
package com.damsoon;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class Main {
// 추가되어야 할 Exception 이 너무 많아 Exception 으로 작성.
public static void main(String[] args) throws Exception {
// 현재 스레드의 클래스로더를 가져온다.
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
// class 라는 단어는 "예약어" 이므로, clazz 라는 이름으로 메타데이터임을 알린다.
Class<?> funcClazz = classLoader.loadClass("com.damsoon.func.FuncClass");
Class<?> testClazz = classLoader.loadClass("com.damsoon.test.TestClass");
// FuncClass, TestClass 의 기본 생성자를 가져온다.
Constructor<?> funcConstructor = funcClazz.getDeclaredConstructor();
Constructor<?> testConstructor = testClazz.getDeclaredConstructor();
// new FuncInstance(); 와 동일한 동작.
Object funcInstance = funcConstructor.newInstance();
// new TestConstructor(); 와 동일한 동작.
Object testInstance = testConstructor.newInstance();
}
}
Command :
# com 디렉토리를 가진 위치에서 이를 실행한다.
# -d 옵션을 통해 .class 출력 디렉토리를 설정하지 않음.
# 따라서, javac 는 .java 와 .class 를 동일 장소에 배치하는 상황이 된다.
$ javac com/damsoon/*.java com/damsoon/func/*.java com/damsoon/test/*.java
Result :
➜ test-1 tree
.
├── com
│ └── damsoon
│ ├── Main.class
│ ├── Main.java
│ ├── func
│ │ ├── FuncClass.class
│ │ └── FuncClass.java
│ └── test
│ ├── TestClass.class
│ └── TestClass.java
위의 명령어, javac 와 더불어 적힌 모든 패키지 경로들은
해당 패키지 구조(폴더구조) 를 유지하며, 각 계층의 파일을 xxx.class 로 만든다.
위의 구조는 .java, .class 가 공존하는 일종의 "테스팅 결과물"임을 절대로 잊으면 안된다.
즉, 폴더(패키지) 구조를 편하게 재사용하려고 이렇게 만든 것이다.
실행 방법
javac 명령어로 각 파일을 .class 파일들로 "컴파일" 했다.
"실행" 을 위해서는 java .. 로 실행하는데,
위에서 말했듯, JVM 은 ClassPath 가 필요하다.
ClassPath 란, com.damsoon... 이 패키지 구조, 즉 폴더 구조가 안착한
디렉토리의 경로를 의미한다.
➜ test-1 tree
.
├── com
│ └── damsoon
│ ├── Main.class
│ ├── Main.java
│ ├── func
│ │ ├── FuncClass.class
│ │ └── FuncClass.java
│ └── test
│ ├── TestClass.class
│ └── TestClass.java
com.damsoon... 패키지가 안착한 디렉토리는, 바로 test-1 이다.
우리가 경로 명령어를 사용할 때, 절대 경로와 상대 경로를 지정하듯,
2 가지의 실행 방식이 존재한다.
1.
# 윈도우의 경우 '${pwd}' 로 작성해야 합니다.
➜ test-1 java -cp $(pwd) com.damsoon.Main
생성자 실행 : FuncClass
생성자 실행 : TestClass
2.
➜ test-1 java -cp . com.damsoon.Main
생성자 실행 : FuncClass
생성자 실행 : TestClass
사실 java ./com/damsoon/Main.java 를 해도 동일한 결과가 나온다.
이를 따로 소개하지는 않았는데, JVM 추가 기능으로서, .java 파일을 즉시 .class 로 바꾸고,
그 결과물을 남기지 않으며, 추가되지 않은 패키지 정보들을 "알아서" 처리해 주는 마법같은 기능이기 때문이다.
그러나, 마법같은 기능에는 더 많은 리소스가 필요하기 마련이다.
이미 .class 파일로 컴파일 된 상황과, 항상 .java 파일을 캐싱하는 것은 크나큰 차이가 존재한다.
따라서,
- 컴파일
- 실행
이 2 가지 단계로 나누어서 실행된다는 것을 잊지 않는 것이 좋다. (IDE 가 대신 수행 해 준다.)
다시 돌아와서,
javac <컴파일할 위치1> <컴파일 위치2> <...>
위의 명령어로 Compile 을 수행하여 계층을 유지하는 .class 파일들을 생성했으며,
java -cp . com.damsoon.Main
위의 명령어로 "현재 위치까지가 ClassPath" 이며, 해당 위치의 com.damsoon.Main
파일에서 JVM 가동하겠다는 명령을 내렸다.
#단, 여기서 무조건 고쳐야 할 명령어가 있다.
위의 명령어들은 잘 수행되었지만, .java, .class 파일이 혼재하는 상황이다.
➜ test-1 tree
.
├── com
│ └── damsoon
│ ├── Main.class
│ ├── Main.java
│ ├── func
│ │ ├── FuncClass.class
│ │ └── FuncClass.java
│ └── test
│ ├── TestClass.class
│ └── TestClass.java
클라우드나, 사설 서버에 올린다고 가정 할 때,
.java 를 사용하지 않는다.(대부분의 경우)
JVM 은 .class 를 load 하여 사용하기 때문이다.
따라서, 우리는 컴파일 시, 따로 생성할 디렉토리를 지정 해 주어야 한다.
나의 경우, result/bin 이라는 예시에 계층을 유지한 .class 파일을 생성해 보겠다.
$ javac -d \ # shell 사용 시, 커맨드가 길다면 "\" 를 사용하는 습관을 길들여 보자.
./result/bin \ # 보기에도 편하고, 명령어 실패 시 어디가 틀렸는지 확인하기 좋다.
com/damsoon/*.java com/damsoon/test/*.java com/damsoon/func/*.java
➜ test-1 tree
.
├── com # .java 가 존재하는 위치
│ └── damsoon
│ ├── ...
├── result
│ └── bin # .class 파일들이 존재하는 위치
│ └── com
│ └── damsoon
│ ├── Main.class
│ ├── func
│ │ └── FuncClass.class
│ └── test
│ └── TestClass.class
위의 결과를 보면,
내가 결과물 디렉토리로 선정한 result/bin 을 생성하고,
그 하위에 "패키지 계층" 과 동일하게 형성된 .class 파일들을 볼 수 있다.
그리고, 이 위치에서 java ... 를 사용하는 것이다.
# 현재 생성된 result/bin 에 들어와 있는 상황
➜ bin java -cp . com.damsoon.Main
생성자 실행 : FuncClass
생성자 실행 : TestClass
혹은
# 생성된 디렉토리에 가지 않고,
# 해당 디렉토리를 ClassPath 로 설정하여 내부 class 파일을 실행한다.
➜ test-1 java -cp ./result/bin com.damsoon.Main
생성자 실행 : FuncClass
생성자 실행 : TestClass
즉, 컴파일 결과물 .class 들은, 내가 javac -d 로 설정한 <root>/result/bin
에 계층적으로 저장된다.
그리고 해당 디렉토리를 중심으로 ClassPath 를 구성하고, 실행하면 된다.
(기존에 혼재하던 .java, .class 중에서 .class 만 지우자.)
위에서 나는 이러한 주제들을 다루었다.
- 메타프로그래밍의 의미
- Java 에게 있어 Package Name 의 의미와 중요성
ClassLoader를 가져와서 사용하는 방법 그리고,ClassLoader의 클래스 인식 방법- 컴파일 방법과 실행 방법
이제 위의 기반 지식들로 충분히 Reflection API 를 설명 할 수 있으리라 믿는다.
(또한 독자 분들도 이해하시리라 믿고, 모르면 왼쪽 프로파일의 Email 로 질문 보내주세요)
3. Reflection - 리플렉션
클래스의 메타데이터와 함께 세트로 Spring 에서 활동하는 매우 중요한 개념이다.
이 글의 초반에 나는
"우리는 비즈니스 논리를 main 급의 메서드에 직접 선언 한 적이 없다" 고 말했다.
"즉, 누군가는 우리의 비즈니스 논리를 직접 넣어주었다는 이야기이다."
그 누군가가 바로 Reflection API 이다.
Reflection API 를 누구한테 설명하느냐에 따라서 그 예시가 달라질 수 있다고 생각하는데,
만약에 JavaScript 를 사용 해 본 분이라면,
생성자를 갈아끼우거나, 메서드, 또다른 내부 객체, 변수 조작 등등..
엄청난 자유도를 보이는데, 마치 언어 자체의 규칙이 어디까지인가 신기 할 정도이다.
물론, JS 에도 한계가 존재하며, 문법적 설탕(Synthetic Sugar)이 매우 많기 때문에,
TypeScript 로 이를 완화할 수 있다.
당연히, Java 는 JS 보다 타입에 있어 비교할 수 없는 엄격함을 보인다.
우리가 처음 Java 를 사용할 때를 기억 해 보자
클래스를 직접 지정하고,
어떤 클래스를 확장 혹은 구현하며,
이 클래스는 어떤 변수, 메서드, 생성자를 가지며....
이들을 직접 컴파일 되기 전, 개발하면서 작성한다.
그런데, Reflection API 는,
런타임 시, 위의 로직을 수행하므로, 거꾸로 생각하면 된다.
"내가 직접" 조작하는 것이 아니라, "프로그램이" 조작한다고 생각해야 한다.
그러나, JS 만큼의 자유도는 아니라는 것을 반드시 기억해야 한다.
위에서 보았던 코드 예제를 바꾸어 보자. :
(여기서 TestClass 는 FuncClass 를 의존성으로 필요로 한다는 가정을 하겠습니다.)
package com.damsoon.test;
public class TestClass {
private FuncClass funcClass;
// TestClass 는 FuncClass 를 의존성으로 삼기 때문에 필요합니다.
public TestClass (FuncClass funcClass) {
System.out.println("생성자 실행 : " + getClass().getSimpleName());
// TestClass 는 FuncClass 의존성이 존재함.
this.funcClass = funcClass;
}
// getClass() 는 this.getClass() 와 동일합니다.
public String toString() {
return "[Class] : " + getClass().getSimpleName();
}
}
package com.damsoon.func;
public class FuncClass {
public FuncClass() {
System.out.println("생성자 실행 : " + getClass().getSimpleName());
}
public String toString() {
return "[Class] : " + getClass().getSimpleName();
}
}
TestClass && FuncClass :
클래스 확인용 toString() 을 @Override 한 것을 볼 수 있다.
toString() 실행 시, 실행된 해당 클래스의 패키지 클래스 이름이 아닌, 단순 클래스가 나온다.
그러나, TestClass 는 FuncClass 타입 변수를 내부에 두고,
생성자에서 FuncClass 타입 인자를 받고 있다.
즉, TestClass 는 FuncClass 의존성이 존재한다.
package com.damsoon;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
public class Main {
// 추가되어야 할 Exception 이 너무 많아 Exception 으로 작성.
public static void main(String[] args) throws Exception {
// 현재 스레드의 클래스로더를 가져온다.
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
// 공식문서에서 clazz 를 사용하는 것을 보면, 클래스 메타데이터는 보통 이렇게 선언하는 것이 Convention 이라는 것을 말한다.
Class<?> funcClazz = classLoader.loadClass("com.damsoon.func.FuncClass");
Class<?> testClazz = classLoader.loadClass("com.damsoon.test.TestClass");
// 인자가 없는 FuncClass 생성자 가져오기
Constructor<?> funcConstructor = funcClazz.getDeclaredConstructor();
// 인자가 1 개인 TestClass 생성자 가져오기 - 인자 없으면 오류나요
Constructor<?> testConstructor = testClazz.getDeclaredConstructor(funcClazz);
// 각 클래스의 생성자를 이용하여 인스턴스를 생성 및 할당.
Object funcInstance = funcConstructor.newInstance();
Object testInstance = testConstructor.newInstance(funcInstance);
// 각 클래스에 선언되어 있는 public 메서드, "toString" 을 가져오기
// 인자가 있다면, 이름 뒤에 인자들을 int.class, String.class 식으로 나열해야 합니다.
// ex - funcClazz.getMethod("something", int.class, String.class)
Method funcToString = funcClazz.getMethod("toString");
Method testToString = testClazz.getMethod("toString");
// 추출된 메서드는 각 "인스턴스 별" 로 실행할 수 있습니다. - invoke
// 인스턴스가 중심이 아닌, "메서드" 가 중심이라는 것을 인지해야 합니다.
// 그리고 getMethod 와 동일하게, 인자가 있다면, int.class, String.class 식으로 인자를 나열해야 합니다.
String funcString = (String)funcToString.invoke(funcInstance);
String testString = (String)testToString.invoke(testInstance);
System.out.println(funcString);
System.out.println(testString);
}
}
Main.java :
main 에서 도드라지는 변화는, 단순히 인스턴스를 생성하는 것 뿐만 아니라,
각 클래스에 요청할 toString 이라는 문자열 치환을 Reflection 이 수행하고 있다는 걸
볼 수 있다.
즉, 내가 직접 클래스에 요청을 넣는 것이 아니라, 결국 프로그램이 스스로 메서드를 실행하는 것이다.
Method 는 클래스 메타데이터 자체에서 뽑아낼 수 있다.
"클래스" 는 하나이지만, 클래스에서 만들어진 "인스턴스" 는 수없이 많다.
우리는 Reflection API 에서, Method 에서 사용 가능한 invoke 라는 메서드를 통해
인스턴스와 인자 타입을 순서대로 넣어 실행하고, 그 결과를 받을 수 있다.
물론, toString() 형태로 인자가 없기에 넣지 않았다.
필요한 모든 패키지 경로를 적지 않고, 원하는 모든 .java 파일을 컴파일하는 방법
# 쉘의 Recursive 표식인 ** 를 이용하여 com/damsoon 및 하위에 존재하는 모든 디렉토리의
# .java 파일을 찾아 컴파일한다.
➜ test-1 javac -d result/bin $(find com -name "*.java")
# 결과물이 출력된 result/bin 을 ClassPath 로 지정.
# 결과물의 com.damsoon.Main 클래스를 기점으로 실행
➜ test-1 java -cp ./result/bin com.damsoon.Main
생성자 실행 : FuncClass # FuncClass 생성자 실행
생성자 실행 : TestClass # TestClass 생성자 실행
[Class] : FuncClass # FuncClass->toString()
[Class] : TestClass # TestClass->toString()
보다시피, Reflection API 를 이용하여 내가 제어하지 않고,
(물론 아직까지는 내가 제어한 것과 비슷하지만)
프로그램이 직접 클래스를 생성하고, 클래스의 메서드를 실행하는 것을 볼 수 있다.
위의 예제에서 해결이 되지 않은 치명적인 약점 - 순환 참조
위의 예제에서는 내가 순환 참조 해결을 애너테이션으로 이어서 해결하는 예제를 보이기 위해
단방향 의존성 참조, TestClass 가 FuncClass 를 의존하는 경우로 만들었다.
그렇다면, 만약에 TestClass, FuncClass 가 생성자 단에서 서로를 요구한다면 어떨까?
TestClass 를 먼저 생성하던, FuncClass 를 먼저 생성하던, 모두 치명 에러를 던진다.
flowchart LR
TestClass <-- 생성 시 서로를 필요로 함 --> FuncClass
이 얘기를 하는 이유는,
우리가 Spring 프레임워크에서 사용하는 수많은 비즈니스 Component들은,
결국 서로를 참조하는 "순환 참조" 패턴이 생각보다 자주 발견되기 때문이다.
부트캠프에서 한달 프로젝트를 NestJS 로 수행하던 과정에서,
기능을 빠르게 먼저 만들어야겠다는 생각에 Service 컴포넌트를 가끔 순환참조로 만들어버렸다.
이는 나중에 컴포넌트들끼리의 결합이 "조금이라도" 더 복잡해 지는 순간,
매우 큰 후회로 다가오게 된다.
flowchart TB
A-Service
B-Service
C-Service
D-Service
B-Service --B 는 C 를 필요로 한다.--> C-Service
B-Service --B 는 D 를 필요로 한다.--> D-Service
D-Service --D 는 A 를 필요로 한다.--> A-Service
A-Service --A 는 B 를 필요로 한다.--> B-Service
A 와 B 가 단순히 서로를 참조하는 형태 뿐만 아니라,
위의 예시처럼 Logic 구조에서 멀어보이는 객체가 "순환 참조(Circular reference)"
를 일으킬 수가 있다.
물론, 이를 미리 예방하기 위해 interface (추상화) 가 존재하지만,
이걸 어떻게 예방하는지 "직접 제작" 하는 것도 매우 좋은 습관이라고 생각한다.
애너테이션@ 없이 순수 코드로 해결 할 수 있지만,
아직 Reflection 과 Annotation 간의 관계를 명확히 해소하지 않았으므로,
여차저차 Annotation 을 사용하며 Reflection 을 조합하여 순환 참조를 해결하는 방식으로
공부 할 것이다.
(왜냐면 간단한 Container 로직이 들어가기 때문에, 어마무시한 코드가 작성될 예정이다.)
너무 많은 개념과 예제가 나와서, 요약정리를 하고 넘어가겠습니다.
Java 로 메타프로그래밍을 구현하기 위해, 수많은 기반 지식을 풀고 해석했다.
이 과정은 절대 순탄치 않았으며, 여타 다른 프로그램과는 다른 JVM 과 Java 의 특성을
먼저 알기 위해 여러 개념들을 풀어서 설명했다.
요약 이후, Spring Container 를 모방한 로직 + Annotation 이 시작된다.
즉, 순환 참조를 완전한 자동화 Reflection 기법으로 풀 예정이기 때문에,
위의 개념들을 반드시 다시 잡고 가야 했다.
1. package
여타 다른 언어들과는 다르게, Java 에서 사용되는 모든 라이브러리, 클래스, 메서드
등등을 사용하기 위해서는, Package Name 을 중심으로 Load 한다.
JVM 에서 클래스나 기능을 가져오는데 "상대 경로" 는 어떠한 의미도 가질 수 없다.
JVM 은 "정확하게 정해진" Class Path 에서 파생된 디렉토리 구조(package) 를 통해,
클래스를 가져올 수 있다.
이는 Java 에 내장된 기본 유틸리티들도 피해갈 수 없다.
내장 유틸리티의 Class Path 를 정하는 것은,
우리가 다운로드 한 jdk-xx 가 존재하는 경로를 통해 유틸리티를 가져오는 것이다.
2. ClassLoader
스프링의 컨테이너를 만드는 핵심 중의 핵심 기능이라고 볼 수 있다.
Java 에서의 메타프로그래밍 수행을 위해 필수불가결한 장치이다.
클래스(인스턴스x) 가 고유하게 가지는 "모든" 변수, 생성자, 메서드 들을 뽑아낼 수 있다.
private 이던, final 이던 상관없다. 전부 뽑아낼 수 있다.
단, 뽑아냈다고 해서 "수정" 할 수 있는 것이 아니다.
JavaScript 를 생각해 본다면, 객체 내부에 변수던 메서드이던 객체이던,
마음대로 조작하고 변경할 수 있지만,
Java 의 객체에서 생성된 인스턴스의 정보를 곧바로 변경할 수 없다.
이는 정보의 변환 권한이 "객체"가 아닌, "내부의 정보" 만이 스스로를 변화시킬 수 있기 때문이다.
우리가 아는 여타 객체 변환과는 다르게, Java 에서의 객체 정보 변환 시스템은 이해가 필요하다.
이를 가능하게 해 주는 것이 바로, Reflection API 이다.
3. Reflection API
ClassLoader 로 뽑아낸 클래스의 데이터와 더불어,
이들을 조회하고 명확한 데이터 클래스로 만들어 낼 수 있는 것이 바로
이 리플렉션 API 덕분이다.
Reflection API 는 이렇게 이해하는게 편하다고 생각하는데,
일반적으로 객체의 행동을 보통 개발자가 관리한다.
(C 수준의 객체 관리 말고.)
우리는 Test test = new Test(); 이러한 간단한 문구로 원하는 타입의 객체를 생성한다.
test.all() 과 같은 문법을 작성하여 메서드를 실행시킨다.
그러나, 위의 문구는 Dynamic(동적) 이지 않다. Static(정적) 이다.
내가 말하고자 하는 것은, 객체의 탄생과 그 활동을 "개발자가 직접" 조작한다는 것이다.
그렇다면, Reflection API 는 어떨까?
ClassLoader 로 뽑아낸 데이터들은 모든 정보의 추출이 가능하다고 했다.
즉,
- 객체를 생성하는 "생성자" ==
Constructor를 뽑아낼 수 있으며, - 객체 내부의 변수인 "필드" ==
Field를 뽑아낼 수 있으며, - 객체 내부의 함수인 "메서드" ==
Method를 뽑아낼 수 있다. - 객체가 상속한 클래스의 정보를 추출할 수 있다. ==
Class<?> - 객체가 구현한 인터페이스의 정보를 추출 할 수 있다. ==
Class<?>[] - 이 객체의 패키지 이름을 추출 할 수 있다. ==
String - 등등..
- 위의 보안 수준이 어떻든 간에 상관없다. 모두 뽑아낼 수 있다.
- But!!, 객체 내부 변수가
private final로 되어있을 경우,
내장 Reflection 으로 수정할 수 없다. (Java 의fianl은 미리 지정되거나, "생성자" 에서만 지정되고 변경 X)
위의 정보를 추출하여,
어떠한 클래스가 올지 모른다. 어떠한 메서드가 실행될 지도 모른다.
그러나, Reflection API 를 통해 다양한 클래스들을 프로그램 자체적으로 관리하도록 만들 수 있다.
이 기법을 극한까지 몰아서 사용하는 프레임워크가 바로, Spring 인 것이다.
위에서 보인 Constructor, Field, Method 와 같은 클래스 타입들은
Reflection API 에서 제공한다.
그리고, Java 코드로 위의 클래스 타입에서 직접 실행한다.
즉, 우리가 직접 객체를 생성하고, 변수를 설정하고, 메서드를 실행하는 것이 아니라,
- 생성자 가져오기 :
Constructor objConstructor = clazz.getDelaredConstructor()
- 인스턴스 생성 :
Object obj = new Object()=>Object obj = objConstructor.newInstance()
- 변수 조작 :
obj.name = "newName"==>Field nameField = clazz.getDeclaredField("name")nameField.set(obj, "newName")
- 메서드 실행 및 조작 :
String result = obj.getName()==>Method method = clazz.getMethod("getName");String result = method.invoke(obj);
위의 예시 리스트들은 모두
obj 내부 생성자와 메서드에 인자가 필요하지 않다는 가정 하에 작성되었습니다.
인자가 존재하는 경우,
Constructor objConstructor = clazz.getConstructor(String.class);objConstructor.newInstance("myName");Method method = clazz.getMethod("setName", String.class);
와 같은 형식으로 인자를 나열하게 됩니다.
Annotation - 애너테이션 '@'
드디어 우리가 Spring Framework 에서 사용하던 Annotation, @ 를 제대로 배우게 된다.
Annotation 은 코드의 Logic 속에서, 단순히 하나의 기능만 담당하지 않는다.
Proxy, AOP, DI, 등등 뿐만 아니라,
특정 클래스를 상속받은 클래스가 @Override 표시를 남겨
조상 클래스의 메서드를 "재구성" 했다는 것을 명시적으로 남길 수 있다.
뿐만 아니라, @Deprecated 를 통해 해당 클래스, 혹은 메서드, 변수가
미래에 있어 지원과 연결성이 끊길 예정이라는 걸 명시 할 수도 있다. (되도록 쓰지 말라는 말)
위의 2 개의 애너테이션을 포함하여, @SuppressWarnings 라는 애너테이션 까지,
내장되어 있는 애너테이션이다. (이건 특정 경고를 꺼 주는 애너테이션)
Custom Annotation 이 핵심이다.
위에 명시한 3 개의 내장 애너테이션은 JVM 이 클래스를 load 하고, execute 하는데
어떠한 영향도 미치지 않는다. 즉, 해당 코드를 보는 개발자에게 알려주기 위한 용도인 것이다.
Java 에는 개발자가 직접 본인만의 Annotation 을 만들 수 있는 기능이 존재한다.
커스텀 애너테이션은 주로 "메타데이터" 에 집중되어 있다.
위에서 우리는 "메타데이터" 를 다루었다.
- 생성자 정보 및 생성자 추출
- 변수 정보 및 변수 Field 추출
- 메서드 정보 및 메서드 Method 추출
- 추출된 Field, Method 를 "내가 아닌", "프로그램" 이 관리하도록 바꿈.
애너테이션은 ClassLoader, Reflection 과 더불어 사용되는 핵심 문법이며,
자바 파일에서 .class 파일로 컴파일 되고, 자바 가상머신, JVM 에 포함되어 동작한다.
우리가 다루는 Annotation 또한 인터페이스, 즉 "클래스 메타데이터" 로서 인식된다.
그러나, 일반적인 class 와는 다른 문법을 보인다.
따라서 Annotation 이 가지는 고유의 특성과, 사용법을 배워야만 한다.
1. Annotation 사용할 때
애너테이션은 다양한 형식으로 선언 될 수 있다.
Spring 을 자주 사용 해 보았다면, 인자가 있거나 없는 애너테이션이나, 중첩되는 애너테이션을 보았을 것이다.
@Entity: 괄호 없이 사용될 수 있다. - (물론 설정을 원한다면 인자를 넣을 수 있다.)@SuppressWarnings(value = "unchecked"): 직접 인자value라고 선언한다.@SuppressWarnings("unchecked"): 인자가 하나이므로 단순하게 값만 넘겨준다.@Author(name = "kong"): 이 밑에@Author(name = "gong")으로 중첩할 수 있다.
뿐만 아니라, 많이 보지 못했던 형태도 가능하다.
@Interned:new @Interned MyObject();==> 인스턴스 생성 시@NonNull:(@NonNull String) str==> 타입 캐스팅 시@Readonly:@Readonly List<...>==> 저장된 리스트는 "읽기만 가능" 하게@Critical:...() throws @Critical ..Exception { ... }
==> 이 에러 발생 시 에러 위험도를 "치명" 으로 올린다.
2. Custom Annotation 선언 기초
우리는 이미 스프링에서 선언된 애너테이션들을 보면, 맨 위에 다양하게 붙어있는
또 다른 애너테이션들을 볼 수 있다.
@Retention, @Documented, @Target, .. 등등이 존재한다.
커스텀 애너테이션에 붙은 이러한 애너테이션들은, 커스텀 애너테이션에 부가적인 기능을 제공한다.
완벽하게 커스텀화 된 애너테이션을 선언 해 보자면,
Example :
// Custom 저자 애너테이션 제작
@interface AuthorAnnotation {
String name();
String comment() default "No Comment";
int currVersion();
String[] metaDatas() default {};
}
// Testing 클래스에 애너테이션 메타데이터 "주입"
@AuthorAnnotation(
name = "Gong Damhyeong",
comment = "첫 커스텀 애너테이션 제작",
currVersion = 1
)
class Testing {
// ...
}
가장 기초적인 형태의 Custom Annotation 을 보면, 이질감이 들 수 밖에 없을 것이다.
우리는 "저자 정보" 를 저장하는 아주 간단한 애너테이션을 만들었다.
문법을 보면, @interface 를 선언함으로서 이는 애너테이션이라고 선언한다.
그런데, 분명 값을 인자로 받는데, String name() 과 같이,
변수가 아니라 메서드 반환 형식으로 선언되어 있다.
이는 애너테이션이 특징으로, Interface 구조가 기본이다. 따라서 변수를 가지지 않는데,
JVM 이 애너테이션을 Proxy 구조로 클래스로 재생성하여 메타데이터로 주입하는 것이다.
또한, 배열 선언과 디폴트 형식이 존재한다.
위에서 선언한 애너테이션의 값 주입으로 생성된 객체는,
// JVM 이 이런 식으로 프록시 객체를 생성하여 애너테이션으로 주입한다고 생각하면 됩니다.
public class AuthorAnnotationProxy extends AuthorAnnotation {
@Override
public String name() {
return "Gong Damhyeong";
}
@Override
public String comment() {
return "첫 커스텀 애너테이션 제작";
}
@Override
public int currVerion() {
return 1;
}
@Override
public String[] metaDatas() {
return {};
}
}
정적 객체로 재탄생하게 된다.
그리고 자신만의 Custom Annotation 을 제작하고 꼭 같이 작성해야 한다고 판단되는 애너테이션은,
바로 @Documented 라고 생각한다.
많은 에디터 프로그램에서 Java 코드의 정보를 javadoc 으로 펼쳐 본다.
따라서, @Documented 를 우리가 만든 애너테이션 위에 붙여주면, 에디터 사양이 펼쳐 보여 준다.
또한, Java 가 내장 기능으로 제공하는 다양한 애너테이션들이 존재하는데,
이들은 애너테이션을 제작하는 데 가장 많이 사용되고, 꿀팁도 주므로 꼭 보는 것을 추천한다.
Spring Framework 가 제작한 애너테이션들은 내장 애너테이션을 사용하여 구현되었다.
그래도 영어 문서이기 때문에, 곤란한 분들을 위해 요약을 하자면,
1. @Deprecated :
더이상 사용되지 않거나, 곧 지원이 중단되는 요소에 덧붙여 사용한다.
만약 이 요소를 사용한다면, 에디터 프로그램이 "경고" 를 생성한다.
또한 Javadoc 코멘트에도 deprecated 를 넣어놓는다.
public class Test {
@Deprecated // 이런 식으로 작성된다. - 이 기능은 지원이 곧 중단 혹은 사용하지 말아라.
public static void plus(int a, int b) {
return a + b;
}
}
2. @Override :
Java 사용자라면 대부분 알고 있는 애너테이션이다.
굳이 사용하지 않더라도, 상속받은 메서드를 재작성하더라도, 딱히 에러는 존재하지 않는다.
그러나, 이 애너테이션은 "Convention" 에서 지대한 영향을 미친다.
상속받은 슈퍼클래스의 메서드를 재작성 한다는 것은, 어떤 상황에서는
수백 ~ 수천의 메서드 중 하나를 재작성 하는 작업일 수도 있다.
만약에 그저 @Override 없이 작성한다면, 재작성 된 이 메서드가 우리가 아는 "그 메서드 이름"
인지 체크해야한다.
그러나, @Override 를 사용하여 작성하면, 메서드 이름이 틀렸는지 확인시켜준다.
이는 매우 복잡한 프로그램에서 오류를 예방하는 데 큰 도움을 줄 수 있다는 것이다.
3. @SuppressWarnings :
자바에서는 특정 에러나, 경고를 맞닥들이게 된다.
지원되지 않는 저수준 기능을 Java 로 구현하기 위해 직접 코드를 작성하다 보면,
논리적으로는 맞지만 Java 컴파일 시 통과시켜주지 않는 경우가 발생한다.
이 때, @SuppressWarnings("해당 에러") 를 선언하여,
컴파일러가 동작할 수 있게 만든다.
4. @SafeVarargs :
메서드나 생성자에서 적용된다.
이 애너테이션은 가변 타입이 들어 올 때, 발생하는 unchecked 에러를 없앤다.
즉, 이 곳에 선언된 가변 타입은 힙의 메모리가 "오염" 되지 않는다는 것을 스스로 보증하는 것이다.
주로 제네릭에서 많이 사용된다.
5. @FunctionalInterface :
Java SE 8 부터 도입된 애너테이션인데, 람다식과 연결되어 있다.
수많은 인터페이스들이 "여러 메서드" 를 추상적으로 구현하고 있지만,
또 다른 수많은 인터페이스들은 "하나의 기능" 만들 추상 구현하고 있는 경우가 있다.
예를 들어서, 계산기 인터페이스가 있는데, int calculate(int a, int b);
@FunctionalInterface
public interface Calculation {
// 미구현 메서드 1개 --> calculate
int calculate(int a, int b);
// static 이나, default 는 개수에 영향을 받지 않는다.
// 이미 구현되어 있으므로.
}
하나의 추상 메서드만 선언되어 있고, 내부 구현이 없다.
그렇다면, 계산기 인터페이스를 사용하는 곳에선,
public static void main(String[] args) {
// 구현되지 않은 단 하나의 메서드를 구현하는 방식으로 생성한다.
Calculation calc = (x, y) -> x - y;
// 혹은 (x, y) -> x + y; 도 가능하다.
}
결국 위의 코드는, 추상화 인터페이스를 람다식으로 선언하여, 우리가 원하는 방식으로 구동하도록
"형식을 지정" 한 것이다.
이제부터는 Annotation 위에 붙는 애너테이션을 소개합니다.
6. @Retention :
Retention 은 영어 말 그대로 "보유" 라는 뜻인데, 이는 Compiler, JVM 과 큰 연관이 있다.
Retention 은 "유지" 라는 의미로, 크게 3 개로 나눌 수 있다. (특수 옵션도 존재함)
RetentionPolicy.SOURCE: 소스 코드 작성 시에만RetentionPolicy.CLASS: 컴파일러가 작동할 때 까지만RetentionPolicy.RUNTIME: JVM 작동시 같이 사용되며, runtime 라이브러리와 조화할 수 있음.
즉, 작성된 이 Annotation 이 "어디까지 보유" 되냐고 보면 된다.
우리는 위에서 다양한 목적과, 형태의 Annotation 을 보았다.
개발자에게 코드 수준에서 개발 생산성을 도와주는
Deprecated, Documented, Override 어노테이션도 존재하고,(SOURCE)
@NonNull 이라는 애너테이션도 존재하고,(CLASS)
Spring 프레임워크에서 사용되는 친숙한 @Component, @Controller, 등등..
이들이 RUNTIME 이다.
SOURCE, RUNTIME 은 추상적이더라도 명확히 이해되나,
CLASS 리텐션은 조금 이해가 되지 않는다.
그래서 AI 에게 이것에 대해 물어보았는데, 주로 IDE, Linter(어떤 에디터에서도 작동) 가
해당 애너테이션에 대한 개발 정보를 참조하기 위함이다.
또한 공식 문서에서는 각 단계별로 엄격한 유지 정책이 적용되는 것 처럼 보이지만
우리가 사용하는 Lombok 같은 도구들이 SOURCE 의 형상을 하고
Compiler 단계에서 난입하여 AST(추상 구문 트리) 를 바이너리 형식으로 직접 바꾼다는 사실을 알게 되었다..
7. @Documented :
에디터들 중에서 Javadoc 을 적극 사용하는 프로그램들이 많다.
우리가 이 애너테이션을 위에 덧붙여 주면, 해당 프로그램들이 커스텀 된 애너테이션에 대해
문서적으로 표현 해 준다.
8. @Target :
이건 간단하게, "어떤 코드를 목표로 하냐" 이냐.
공식문서의 내용을 참고로 하여 펼쳐볼 건데,
이를 통해 Java 가 크게 코드를 어떤 카테고리들로 분리하고 있는지 볼 수 있다.
ElementType.ANNOTATION_TYPE: 이건 애너테이션을 목표로 한다.ElementType.CONSTRUCTOR: 생성자를 목표로 한다.ElementType.FIELD: 클래스 내부의 변수를 목표로 한다.ElementType.LOCAL_VARIABLE: 메서드 내부 구현 시 콜스택에 잠깐 나타났다 사라지는 지역 변수를 목표로 한다.ElementType.METHOD: 클래스 내부의 메서드를 목표로 한다.ElementType.PACKAGE: "패키지 선언" 을 목표로 한다.ElementType.PARAMETER: 메서드 내부의 인자(args) 를 목표로 한다.ElementType.TYPE: 어떠한 타입이라도(내장타입 및 클래스 전부) 목표로 한다.
9. @Inherited :
이는 커스텀 중인 이 애너테이션이 특정 Super Class(상위 애너테이션) 에게 상속받음을 알린다.
예를 들어, 상속받은 특정 클래스는 상위 클래스의 구현된 메서드를 "모두 구현하지 않을 수도 있다."
애너테이션도 동일하므로, 유저가 이 애너테이션을 통해 무언가를 실행하려 할 때, 없을 수도 있다.
이 때 @Inherited 에 작성된 슈퍼 클래스(애너테이션) 을 참조하겠다는 것이다.
10. @Repeatable :
작성중인 현재 애너테이션이 작성된 장소에 여러번 다시 작성될 수 있음을 알린다.
예를 들어, 클래스 작성자가 2명이라면, @AuthorAnnotation 을 2 번 작성할 수 있게 만드는 것이다.
만들게 될 애너테이션의 기능
- Container 에 들어갈 클래스를 표식하기
- Proxy 기능 추가하기
- 순환 참조 해결하기
솔직히 이 글을 작성하면서 짧게 카테고리를 잡아 8 개의 글로 나눌 수 있는 분량을,
하나의 Article 로 작성함으로 인해서 글이 복잡해 졌다고 생각한다.
그러나, 핵심적인 기술인 Spring Container 를 모방하는 것 자체가 난이도가 높으므로,
이 정도 복잡함을 감수하고 작성해야겠다고 판단했었다.
Annotation 을 배우느라 조금 길이 잘못 빠진 느낌이 있지만,
앞으로 작성하게 될 코드의 구조를 알고자 하시는 독자분이 계시다면, 일일히 파악하기 위해
꼭 짚고 넘어가야 한다고 생각했다.
복잡한 명령어를 shell script 로 압축하자.
나는 위에서 예제를 만들고, 컴파일 및 실행 할 때,
javac -d result/bin $(find com -name "*.java")java -cp ./result/bin com.damsoon.Main
이렇게 com.damsoon.Main 을 통해 JVM 을 실행했다.
이에 대한 명령어를 하나로 압축 할 것이다.
딱히 Shell Script (bash) 에 대해서 관심이 없다면,
복사 붙여넣기 해도 된다. (단, 이 파일은 Project Root 에 위치해야 함)
#!/bin/bash
echo "컴파일 & 실행 구문 시작"
javac -d result/bin $(find com -name "*.java")
java -cp ./result/bin com.damsoon.Main
echo "컴파일 & 실행 구문 종료"
이 파일을 만들어도 바로 실행 할 수 없다.
이에 대해서 실행 권한을 부여 해 준다.
➜ test-1 ls -al
total 24
...
# 현재 실행 권한 'x' 가 없는 것을 볼 수 있음
-rw-r--r--@ 1 gongdamhyeong staff 177 Dec 19 01:44 compile-and-execute.sh
...
$ chmod +x ./<위에 만든 파일명>.sh
➜ test-1 ls -al
total 24
...
# 어떤 사람이던 실행할 수 있음.
# 단, 특정 단계의 사람(보안)만 실행하길 원한다면,
# chmod u+x ... 로 실행하면 된다.
-rwxr-xr-x@ 1 gongdamhyeong staff 177 Dec 19 01:47 compile-and-execute.sh
...
그리고 나서, 실행을 해 보면,
➜ test-1 ./compile-and-execute.sh
컴파일 & 실행 구문 시작
생성자 실행 : FuncClass
생성자 실행 : TestClass
[Class] : FuncClass
[Class] : TestClass
컴파일 & 실행 구문 종료
정상적으로 실행 되는 것을 볼 수 있다.
애너테이션 요약하고 넘어가기
이미 이 글은 수많은 개념과 예제를 작성했기 때문에, 애너테이션과 다른 개념을 최소한으로 엮으려고 한다.
여기까지 읽으신 분이 있으시다면, 감사를 표합니다.
우리가 애너테이션을 배우기 전에 다룬 것을 한 문장으로 "압축" 해 본다면,
"Metadata 를 다루고 객체를 조작하는 방법" 이다.
이 주제가 매우 어려운 이유는,
코드의 메타데이터라는 개념이 매우 추상적이고 활용성이 넓기 때문이다.
단순히 모든 타입을 어우를 뿐만 아니라, 프로젝트의 위치와 정보까지도 포함한다.
또한, 객체를 조작하는 개체는 "나 자신" 이 아니라, "프로그램" 이기 때문이다.
이 메타데이터를 다루는 과정이 매우 복잡한 가운데,
아예 객체, 메서드, 타입, 인수(args) 자체에 메타데이터를 꽂아버리는 기능이 있다.
그게 바로 Annotation(@) 이다.
새로운 개념을 배우면서 오히려 애너테이션이 어렵다고 느낄 수 있겠으나,
난잡해 질 수 있는 메타데이터를 정리 한 것이 Annotation 이라고 볼 수 있다.
Annotation 의 다양화
프로그래밍 고대 시절의 Java 가 아닌, 현대적 Java 를 접하고 사용하는 우리는,
@ 표식을 밥먹듯이 본다.
Annotation 은 하나의 역할로 묶을 수 없을 만큼 다양한 영역에서 활동하게 된다.
그러나, 확실하게 나눌 수 있는 것은 Code, Compiler, JVM 이다.
애너테이션의 Retention 부가 기능을 통해,
이 애너테이션이 어디서 활동할지, 어디까지 유지될 지 선택할 수 있는 것이다.
또한 Java SE 8 이후로 FunctionalInterface 애너테이션이생성되며,
Java 의 애너테이션 사용은 부흥을 맞았다.
내장 애너테이션이 아닌 커스텀 애너테이션을 직접 제작하여 내가 원하는 기능을 제작할 수 있는데,
이 과정에서 민낯의 애너테이션을 제작했다.
이를 통해, 애너테이션의 민낯은 정확히 "메타데이터" 를 향한다는 것을 알 수 있다.
또한, ClassLoader 는 인식하는 객체 뿐만 아니라, 붙어있는 애너테이션들을 분석하는 데에도 탁월하다.
이러한 기능을 통해 Spring Container 는 개발자들의 수많은 요구사항에 유연하게 대처할 수 있다.
Spring Container 를 만들기 전, 구조화 하자.
먼저 만들게 될 프로그램의 기능의 요구사항을 미리 만들어 놓아야 한다.
(Spring Container 의 모든 기능을 구현하지 않으므로.)
내가 만들 컨테이너는, 단일 객체, 의존성 해결에 초점이 맞춰져 있다.
주의사항 - CGLIB 가 아닙니다.
조금 먼 위쪽에서 ClassLoader 예시를 들 때, 순환 참조를 피하기 위해
양방향 참조를 피했다.
이를 해결하는 방법으로는,
- 모든 객체의 Proxy 객체를 만들어서 주입한다.
(쉽게 해결되나 성능적 이슈 - 비효율 리소스 사용) - 바이트코드 수준을 수정하는 라이브러리를 사용한다. (EX -
CGLIB= Spring 이 사용) - 모든 객체의 생성자 실행 단계에서 필요한 의존성을
null로 주입한 뒤, 내가 해결한다.
- 내가 상상한 방식
- 외부 라이브러리에 의존하지 않고, 최대한 Java 로 해결하기 위함
- 당연히
2번보다는 시작이 느리며, 오류의 위험성이 존재함.
Spring Framework 는 당연히 "속도", "유지보수", "에러 관리" 면에서 CGLIB 외부 라이브러리가 압도적으로 효율적이기 때문에 사용할 것이다.
그러나, 나는 Java 가 가진 내장 기능 및 Reflection API 와 Logic 을 최대한 활용하여
Container 를 다른 형태로 구현 할 것이다.
Spring Container 기능 요구사항
단일 객체, 객체 의존성 해결, 순환참조 예방 등등..
이것이 Spring Container 의 목적이다.
여기서 "단일 객체" 란, 실행되고 있는 프로그램에서
내가 선언한 객체가 "단 하나" 만 존재하며, 다른 장소의 여러 객체가
생성될 때 마다 즉시 생성하지 않고, "주소만 참조" 하는 형태이다.
생각 해 보면 순환 참조 뿐만 아니라,
의존성에 문제가 없는 수십 개의 객체들을 컨테이너에 담을 때도
다양한 문제가 제기된다.
예를 들어 보자.
flowchart TB
First("First-Object")
Second("Second-Object")
Third("Third-Object")
First -- 의존(depend) --> Second
Second -- 의존(depend) --> Third
단순하게 그대로, 1 번째는 2 번째를 요구하고, 2 번째는 3 번쨰를 요구한다.
위의 3 개의 객체는 모두 Spring Container 에 들어가야 한다.
위의 3 개의 객체는 ClassLoader 가 어떤 순서로 가져올 지 모른다.
최선은 3 번째부터 1 번째까지 역순으로 Load 하는 것이겠지만,
"수십 ~ 수천" 개의 컴포넌트가 원하는 순서대로 Load 할 리는 없다고 생각해야 한다.
그러면, 어떻게 인스턴스를 생성해야 하는건가?
1 번째, 2 번째는 모두 의존성을 가지고 있다.
"의존성을 가진 객체는 의존성 해결 후, 완성된 인스턴스가 되어야만 한다."
즉, 이 말은 ClassLoader 가 이 2 개중 하나를 먼저 로드하면, 완성된 인스턴스를 생성 할 수가 없다는 의미이다.
그래서 떠올린 방식이 있다.
{"의존성이 필요한 생성자에 null 을 먼저 넣어 완성 된 것 처럼 만들자"}
이게 무슨 의미냐면, 모든 객체들은 Spring Container 에 있는 인스턴스 주소를 가져와서
단일 객체들을 완성해야 하는데, 어떤 생성자를 먼저 실행할 지 모른다.
이는 대부분의 경우 의존성 문제로 인해 프로그램이 에러를 뱉으므로,
"일단 Spring Container 를 채운 이후, 의존성은 프로그램 로직으로 해결" 하는 것이다.
null 을 넣어 인스턴스를 생성한 이후의 Spring Container
flowchart TB
subgraph Container["Spring-Container"]
direction TB
First("First-Object")
Second("Second-Object")
Third("Third-Object")
First --Second 라고 착각--> null1("Field - null")
Second --Third 라고 착각--> null2("Field - null")
end
위의 그래프를 보면 이러한 결과를 도출할 수 있다.
- 프로그램은 의존성이 전부 확보 되었다고 생각하나, 논리적으로 전혀 그렇지 않다.
- Spring Container 는 일단 "모든 객체 인스턴스"를 생성했다.
위에서 일부로 힌트로 Field 를 넣어두었다.
위에서 Constructor, Method, Field 와 같은 Reflection API 를 다루었다.
여기서 Field 는 인스턴스와 더불어, 원하는 값으로 재변경 할 수 있다.
(단, private final xxxx.. 식으로 final 이 붙은 값은 변경 할 수 없다.)
위에서 Spring Container 생성은 되었다.
이제 의존성을 해결해야 한다. (앞으로 만들게 될 Logic 으로 직접.)
컨테이너에 들어와야 할 객체들을 인식 할 때, 완성된 객체가 있다면 바로 주입하면 된다.
그러나 대부분의 경우는 의존성이 바로 해결되지 않으므로,
직접 로직을 작성해 보았다.
빠진 설명 && 다른 방식 채택으로 인한 문제점
기능을 완성하고 나서 글을 점검하고 있는데,
앞으로 나올 Java 의 내장 타입과 더불어 Logic 을 이해하기 위해
글을 덧붙여야 한다고 판단했다.
Spring 의 객체 컨테이너 방식
Spring 은 외부 라이브러리 CGLIB 를 이용하여 객체를 완성한다.
만약에 클래스 Compo1 이 클래스 Compo2 를 의존성으로 원하여,
생성자 인자에 넣어두고, AutoWired 를 표식 해 놨다고 가정 해 보자.
그렇다면, Spring 은 Compo2 가 "완전한 객체" 가 될 때 까지,
Compo2 가 원하는 의존성을 찾고, 또 필요한 의존성 객체가 완성 될 때 까지 찾고 완성하고..
따라서 Compo2 가 완전한 의존성이 되었을 때, Compo1 의 Compo2 의존성이 드디어
내부에 넣어지는 방식이다. 의존성 방식이 DFS 알고리즘에 해당한다고 보면 된다.
Spring 은 특정 객체가 원하는 객체가 DFS 방식으로 의존성을 완료하지 못했을 때,
(대부분은 순환 참조 문제겠지만,) 에러를 낸다.
이 때문에, 순환참조가 발생 가능한 구조에서 @Lazy 표식을 의존성에 넣어두지 않으면,
Spring 은 서버의 시작을 "거절" 한다.
뿐만 아니라, Spring 은 CGLIB 를 사용하는데,
이 라이브러리는 JVM 이 실질적으로 실행하는 .class 파일, 즉 바이너리 파일을,
강제로 수정하여 원하는 코드나 기능을 추가하는 라이브러리이다.
무엇 때문에 "강제로 바이너리 파일을 수정하냐?" 할 수 있는데, Java 자체에 제약이 많다.
가장 대표적인 예시로, Lombok 라이브러리에서 @Getter, @Setter 와 같은 기능은
컴파일 시 컴파일러가 객체에 Method 를 넣어주는 것이 아니다.
정확히 컴파일 될 때, 바이너리 파일에 접근하여 해당 객체 .class 파일에 접근하여
해당 필드 변수명과 결합하여 getName() 과 같은 메서드와 반환문 자체를 수정한다.
게다가 AOP 기능, 혹은 내부적인 Proxy 를 만들 때도 매우매우 유용하다.
내가 만들려는 컨테이너의 방식
나는 "순수 Java 와 내장 기능"만을 사용하여 컨테이너를 제작한다는 "제약" 을 가지고 시작한다.
물론 Spring 과 비슷하게 현재 객체가 필요로 하는 의존성이 완성되도록 DFS 방식을 만들 수 있었지만,
프레임워크에서 개발 시 발생하는 순환 참조에 대한 제약이 강력하다고 생각했다.
당연하게도, 순환 참조를 예방하기 위해 interface 사용을 권장 할 뿐만 아니라,
너무 꼬여서 스파게티가 될 경우 @Lazy 를 접두어로 붙여 의존성을 풀 수도 있다.
그러나 나는 이런 생각을 했다.
"컴포넌트를 선언하고, 서로만을 원하는 순환 참조가 발생하더라도, 그게 꼭 오류가 되어야 할까?"
이는 어떠한 Architecture 를 구상하더라도, 스파게티 코드를 생성할 수도 있는 위험한 발상이다.
그러나, 오히려 인스턴스 식품처럼 구조를 빠르고 간편하게 생성 할 수 있는 방식이기도 하다.
(단 객체의 "생성자" 에서 의존성을 필드 변수에만 주입해야 한다. 다른 로직을 넣으면 안된다.)
따라서 "순환 참조(Circular Dependency)" 가 된 의존성들을 경고하고,
결국 모든 의존성을 해결 해 주는 것이 어떨까? 생각했다.
그 방식을 해결 해 줄 수 있는 방식이 "선 컨테이너 완성 후 의존성 해결" 이다.
물론, 컨테이너는 정말 완료되지 않았다. 가짜 의존성으로 가득 찬 컨테이너는 완성되지 않았다.
그러나, 클래스 메타데이터를 모두 읽으며 필요한 의존성들을 "기록" 해 둔 뒤,
의존성을 해소 해 주는 것이다.
이 방식은 "선 의존성 해결 후 컨테이너 완성" 이라는 로직을 가진 Spring 과는
완전히 방식이 정반대 인 것이다.
하지만 순수 Java 및 내장 기능만 운용한다는 제약은 프레임워크에서도
사용할 개발자에게조차 제약을 만들 수 밖에 없도록 만들었다.
가장 중요한 제약은 바로
- "필드변수, 생성자 인자 변수명 동일"
- "프록시 적용 시 적용될 메서드에 대한 인터페이스 작성 및 적용"
이다.
이는 언어의 제약과 더불어 로직의 한계였는데, 나는 ByteCode File(.class)
자체를 수정하지 않기 때문이다.
Java 에서 프록시를 적용하는 방법은 내장 API 를 사용하면 되는데,
이 기능이 범용성 있게 사용하기 매우 어려우며,
가장 중요한 것은 프록시 적용 객체는 "기존 객체와 완전히 다르다".
기존 객체와, 이 객체에 프록시가 적용된 새로운 객체의 공통점은,
Interface 단 1 가지밖에 없다.
만약,
TestComponent 에 특정 Proxy 를 적용하여 나온 결과는 TestComponent 가 아니라,
Proxy$$6 이라는 것이다.
이로 인해 곤욕을 겪었지만, 커스텀 Annotation 을 제작하여 해결 한 상태이다.
앞으로 제기될 여러 가지 문제로 인해,
생성자에서 인자로 주어진 여러 개의 의존성과, 객체의 필드 변수의 이름은 동등해야 한다.
이 제약이 강력하게 주어진다. 틀리면 에러를 던지고 프로그램이 종료되도록 설계했다.
아직 이해되지 않겠지만, 나는 생성자 인자로 null 값을 던지고 이후에 필드 변수로 값을 주입하여
의존성을 해결하기 때문에, 이에 대한 연결성을 확보하기 위해 "변수명" 이라는 제약이 생겼다.
나머지 이야기는 앞으로 나올 예제와 설명으로 인지하게 된다.
그리고 "객체 의존성 해결" 이라는 부분은 생각보다 어렵지만,
그 만큼 Reflection API 로 해결 할 수 있는 방안이 많다.
내 방식은 절대로 또 다른 정답이 될 수 없다. 그저 "다른 방식" 일 뿐이다.
다시 돌아와서 의존성 해결은 어떤 방식으로 진행하게 되나?
@Component 역할을 하는 @MyComponent 애너테이션을 가진
모든 객체 메타데이터 Class<?> 타입의 정보를 추출하여 순회한다.
그리고 객체 메타데이터는 Convention(관례) 적으로 clazz 라고 표현한다.
HashMap,ArrayList등등 자료구조를 사용하게 된다.
com.damsoon.component.xxx를 원하는(Field, Object)들을 저장해 둔다.
HashMap<String, ArrayList<해당 의존성 대기 자료구조>>로 의존성 대기 논리를 만든다.
- 아마 위 자료구조에는 (객체 변수)
Field, 인스턴스(Object) 가 들어갈 것이다.
- 인식한 클래스 메타데이터의 생성자를 추출한다.
clazz.getConstructors(); ==> Constructor[]
- 생성자의 인수를 추출한다.
Method.getParameters(); ==> Parameter[]
- 생성자 인수(args) 에 선언된 클래스 패키지 이름을 추출하여 의존성으로 등록한다.
Method의 인자들은Parameter이라는 내부적인 API 자료구조로 존재한다.Parameter[]에서 각 파라미터의 정의Component1 com1가 있을 경우,- 이 "객체" 는
com.damsoon.component.Component1이라는 의존성을 필요로 한다. - 이를
HashMap<String, ArrayList<의존성 대기 자료구조>>에 넣는다.
- 인자에
null주입 후, 인스턴스를 생성한다.
- 가짜 의존성
null을 인자로 넣어 의존성이 완성 된 것 처럼 만든다.
- 생성자 인자 이름과 동일한
Field를 추출하고, 인스턴스를 기준으로 필요한 의존성을 Map 에 저장한다.
생성자 인자(Test test) == 필드변수(private Test test)- Reflection 에서
객체.변수 = 새로운 값으로 설정할 수 없다. - Java 자체의 엄격한 규칙으로 인해, 값 변경의 "권한" 은 객체 내부의 자료구조들이 가진다.
- 즉,
Constructor,Method,Field, 등등이 권한을 가진다. - 따라서 의존성 대기 리스트를 등록 할 때,
Field(필드), Object(미완성 인스턴스)쌍을 이룬다. - EX -
field.set(Object, 새로운 field 값 - 타입 혹은 인터페이스 일치)
- 생성자 단에서 요구한 의존성이 여러 개 일 수 있다는 것을 명심해야 한다.
- 우리가 추출한
Constructor에서 필요한 인자들(Parameter) 는 여러 개 일 것이다. - 객체가 원하는 의존성은 10 개일 수도 있다. ==> 10 개의 의존성 파라미터가 존재한다.
- 시각적으로 보면, "패키지 클래스" 의존성이 필요한 인스턴스들이 "대기줄을 선다" 라는 느낌
- 패키지 의존성은
com.damsoon.component.xxx..문자열을 의미한다. HashMap<String, List<의존성 대기 자료구조>>는
"..damsoon.component1"을 원하는 "필드-객체" 쌍의 배열을 의미한다.
Annotation 을 표식한 모든 클래스의 인스턴스를 컨테이너에 집어넣는 상황에서,
모든 인스턴스가 컨테이너에 등록되었을 때 의존성을 풀기 시작할 패키지 클래스들도 필요하다.
즉, 내가 위에서 HashMap<String, ArrayList<ResolveWait>> 라는 의존성 대기 맵을
만든 것과 동일하게,
컨테이너가 컴포넌트들을 순회하며 "의존성이 필요없거나, 해결되어 있는 경우,"
이들을 따로 넣어둘 Queue 가 필요하다.
즉, Queue<객체("패키지 이름", Object - 인스턴스))>
모든 컴포넌트들을 순회하며 필요한 의존성을 등록한 뒤,
(HashMap<String, List<의존성 대기 맵>>) ==> 의존성 대기 Map
Queue 에서 먼저 완성되어 있던 객체를 꺼내,
Object instance = Queue.poll(); ==> 진짜 완성된 객체 인스턴스
의존성 대기 맵 HashMap 에서 "패키지 이름" 을 기준으로 ArrayList 를 뽑는다.
String 인스턴스 패키지 이름 = instance.getClass().getName();
(실제로는 Queue<CompeteObject> 라는 자료구조로, 인스턴스의 패키지 이름이 동봉되어 있다)
String 인스턴스 패키지 이름 = completeObject.getFullName();
이 때, 패키지 이름은 com.damsoon.component.Component1 와 비슷하다.
따라서 완성된 인스턴스를 뽑았다면, com.damsoon.component.Component1 일 것이라고 가정하자.
그렇다면 위의 패키지 객체, 즉 인스턴스를 원하는 "의존성 대기 리스트" 가 존재 할 것이다.
컴포넌트1 을 원하는 다양한 객체들이 존재 할 것이다.
컴포넌트1 이 완성되었으므로, 이를 원하는 대기 인스턴스들의 필드 변수에 컴포넌트1 을 꽂아주어야 한다.
의존성 대기 맵에서 field, instance 리스트를 가져온다.
`List<의존성 대기> = 의존성map.get("com.damsoon.component.Component1")
이 List 는, Component1 을 받기 위해 대기중인 Field-Object 의 쌍들이다.
List 를 순회하며 field.set(대기 instance, 완성 instance) 로 실행한다.
이로서 Component1 을 원하는 모든 대기 의존성들은 Component1 에 대해서
의존성을 해소했다.
(이로서 대기 의존성 객체는 모든 의존성을 해소 했을 수도, 아직 해소를 다 하지 못했을 수도 있다.)
그리고 순회한 com.damsoon.component.Component1 를
HashMap 에서 remove 한다. (Component1 은 전부 해소되었기 때문이다.)
즉, 위의 과정은 HashMap 에서
Component1 객체를 필요로 하는 대기 의존성 List 를 뽑아 순며하며
Component1 을 원하는 모든 객체가 Component1 을 "해소" 했다.
그렇다면, 원래 "대기 의존성" 이던 객체가, Component2 라고 가정하자.
즉,
// 예시
package com.damsoon.component;
@MyComponent
// Component2 는 패키지명으로, "com.damsoon.component.Component2" 이다.
public class Component2 {
// Component1 은 패키지명으로, "com.damsoon.component.Component1" 이다.
private Component1 component1;
@MyAutowired
// 인자의 타입 패키지명은 위와 동일하다.
public Component2(Component1 component1) {
this.component1 = component1;
// 여기서 타 컴포넌트의 로직이나 변수를 참조하면 안된다.
// Spring 이나 내가 만드는 프로그램이나 동일하다.
}
// ...
}
위에 작성한 Component2 는 원래 "의존성 대기 맵" 에 등록되어 있었다.
그런데, 위의 코드를 보자.
Component1 이 해소되었다면, 위의 객체는 여전히 대기해야 할 인스턴스일까?
당연히 아니다. ==> 이를 Queue 에 넣어준다.
Queue 에서 하나씩 완성된 인스턴스를 뽑아 의존성을 넣어 줄 때 마다,
의존성 대기 인스턴스는 "완성된 인스턴스" 로 전환되며 Queue 에 새로이 넣어 주는 것이다.
이러한 방식으로 Queue 가 Empty 될 때 까지 반복한다.
EX - while(!completeQueue.isEmpty()) { ... }
복잡한 로직을 정리하는 좋은 방식 중 하나는 시각화 하는 것이다.
flowchart TB
Spring-Container["모든 객체의 메타데이터를 순회하며 <br/> 의존성 존재 -> 의존성 대기 Map <br/> 의존성 미존재 -> 완성 인스턴스 Queue"]
HashMap("HashMap - 미완성 컴포넌트가 등록됨")
Queue("Queue - 완성된 컴포넌트들이 등록됨")
Spring-Container --> HashMap
Spring-Container --> Queue
Queue1("완성 컴포넌트 의존성을 필요로 하는 다른 컴포넌트들에게 주입한다.")
Queue -.-> Queue1
HashMap1("완성된 컴포넌트 패키지 이름으로 ArrayList 를 추출하여 의존성을 해결 해 준다.")
HashMap -.-> HashMap1
Queue1 --> HashMap1
Complete1("의존성이 해소된 컴포넌트는 Queue 로 이동한다.")
HashMap1 --> Complete1
Complete1 --> Queue
Complete1 ----> Complete2("Queue 가 비워 질 때 까지 반복한다.")
여기서 중요한 것은,
"컴포넌트의 의존성이 해소되었다는 것을 어떻게 아는가?" 라는 것이었다.
즉, 의존성이 해소되었다는 것을 알기 위해서는 Tracker 를 만들어야 한다.
어려운 구조는 아니고, 이러한 기능의 자료구조 Class 를 만들면 된다.
이것 또한 HashMap 구조로 만들면 되는데,
Map<Object, AtomicInteger> 로 등록하면 된다.
(AtomicInteger 인 이유는, Integer 는 불변성을 가져 값의 변경이 어렵기 때문.)
또한, 왜 SPRING 이 @Lazy 를 사용하는지도 깨닫게 되었다.
우리가 생각하는 상황의 순환참조는 주로 하나의 "원" 형태로 이루어 질 것이다.
위에서 내가 만들었던 그래프 예제를 다시 가져와 보면,
flowchart TB
A-Class
B-Class
C-Class
D-Class
B-Class --B 는 C 를 필요로 한다.--> C-Class
B-Class --B 는 D 를 필요로 한다.--> D-Class
D-Class --D 는 A 를 필요로 한다.--> A-Class
A-Class --A 는 B 를 필요로 한다.--> B-Class
이 예제에서 C-Class 는 의존 객체가 없으므로, 완성된 인스턴스로 간주한다.
그렇다면, 여기서 발견할 수 있는 원형 형태의 순환 참조는
A --> B --> D --> A 형태이다.
단순한 원 형태이므로, A, B, D 중 하나만 Proxy 구조로 변형해도 해소가 가능하다.
그러나, 여기서 누구 하나라도 생성자 메서드에서 "의존성을 등록하는 것" 뿐만 이 아니라,
"의존성의 변수 혹은 메서드 참조" 시 무조건 Error 가 난다.
순환참조는 애초에 논리적 오류이다.
예를 들어서 이런 Java 코드가 있다고 가정 해 보자.
class A {
public B b;
public A (B b) {
this.b = b; // 이건 해결 가능 -> 가짜 의존성 주입으로
b.init(); // 이건 Spring 으로도 해결 불가능
}
public void init() {
System.out.println(b.toString());
// ...
}
}
class B {
public A a;
public B (A a) {
this.a = a; // 이건 해결 가능 -> 가짜 의존성 주입
a.init(); // Spring 으로도 해결 불가능
}
public void init() {
System.out.println(a.toString());
// ...
}
}
내가 예제로 작성한 위의 코드는 순환 참조의 완벽한 예시이다.
그냥 각각의 클래스 Field 에 인스턴스를 넣는 것도 Reflection API 덕분인데,
심지어는 아직 제대로 생성되지도 않은 객체를 서로 실행하게 된다.
그러니, 서로가 서로를 생성하는 과정에서 Code CallStack 이 쌓여
StackOverFlowError 에러가 나거나,
혹은 객체의 의존성 임시 완성을 위해 가짜 의존성 null 이 존재하여
NullPointerException 에러가 난다.
Spring 은 Bean 관련 오류로 출력 될 것이다.
만약에, 순환 참조 상태에서 서로를 초기화하는 init() 역할의 메서드를 넣고 싶다면,
@PostConstruct 라는 애너테이션과 Method 를 작성하여 "따로 분리" 하면 된다.
(Spring 을 사용한다는 가정 하에. - 기본 사양에서는 안됩니다.)
위에서 보인 "생성자" 에서 서로의 메서드를 사용하는 것을 빼고,
A <--> B 의 단순한 상호참조 관계에서조차,
의존성 완료 Queue 에 들어갈 수가 없다. (A 와 B 가 서로 요구할 경우)
그렇다면, A, B 는 영원히 의존성이 해결되지 않는다.
이 때는 강제로 뜯어서 해결하는 수 밖에 없는 것이다.
flowchart TB
step-1("A 한테 미완성 B 를 넣어준다.");
step-2("A 는 자신이 완성되었다고 생각한다.");
step-3("A 는 완성 인스턴스 Queue 로 들어간다.");
step-4("Queue 에서 A 를 뽑는다.");
step-5("A 를 필요로 했던 B 가 의존성 해결이 된다.");
step-6("이로서 A 와 B 모두 실질적으로 의존성이 해결 되었다");
step-1 --> step-2 --> step-3 --> step-4 --> step-5 --> step-6
이 과정은 마치 심장을 수술하는 과정처럼 매우 촘촘하게 Logic 이 구성되어야 한다.
이 과정에서 사소한 에러라도 일어나면 프로그램은 박살이 나기 때문이다..
Annotation 요구사항
Container 는 유일 객체로 제작하며 순환 참조가 발생하지 않도록 만들어 주어야 한다.
그렇다면, Annotation 은 어떠한 역할을 수행하게 될까?
익숙 한 대로, 혹은 얘기 한 대로, Spring Container 에 넣을 것이라는 표식을 새기는 용도이거나,
IDE 가 프레임워크를 잘 사용하도록 유도해 주는 장치가 되기도 한다.
Annotation 은 Java 의 interface 형태 자체는 아니지만, 거의 동일한 속성을 지닌다.
따라서 Annotation @interface 선언시, 메타데이터로서 가질 일종의 변수나 객체를 선언 할 수 있다.
즉, ClassLoader 는 메타데이터를 읽는 과정에서 우리가 평범히 아는
class, interface, Method, Field(변수), Constructor 뿐만 아니라,
@interface 로 선언된 Annotation 의 내부 정보까지 읽을 수 있다.
(interface 와 Annotation 전부 Class<?> 타입이다.)
클래스 로더는 "클래스", "메서드", "생성자", "필드" 등에 붙은 Annotation 을 읽고,
나는 클래스 로더가 가져온 정보를 토대로 각각 다른 행동을 수행하도록 만든다.
이미 먼 길을 와서 까먹었을 확률이 매우 높지만, 위에서 ClassLoader 를 이용하여
FuncClass, TestClass 메타데이터를 가져오고, 이를 "내가 직접" 정해서 실행하는 것이 아니라,
"내가 정한 규칙 혹은 로직에 따라 JVM 이 판단해서 실행" 하는 로직을 작성했었다.
그렇다. 메타프로그래밍이다.
Annotation 은 타입에 따라 다른 행동을 하는 지침 역할을 할 뿐만 아니라,
내부에 개발자가 미리 선언 해 놓은 메타데이터에 따라 다른 과정을 거칠 수도 있다.
ClassLoader 와 Annotation 은 찰떡궁합이다.
"Spring Container 제작과 Annotation 의 역할"
이미 위 파트에서 Spring Container 를 어떻게 제작 할 건지, 의존성은 어떻게 해결 할 것인지 작성했다.
Annotation 은, 어떤 class 가 Container 에 들어가는지, 해당 클래스 인스턴스가
"나중에" 의존성이 해결되어도 되는지(순환참조 - Circulation Reference) @Lazy
이에 대한 메타데이터를 코드 작성 시 개발자가 선언하도록 만들 것이다.
(그래도 스파게티 코드는 지양하도록 만드는게 옳다고 생각합니다.)
그러나, 여기서 Proxy 적용법에 대해서 생각 해 보아야 한다.
나는 Proxy Class 제작을 JS 로 전문 제작한 적이 있어 저 ~ 중급 수준의 언어에서도
이와 비슷하게 만들 수 있지 않을까? 생각했다.
다행히 Java 에서 자체적으로 Reflection API 중 Proxy 가 존재한다.
여기서 Java Reflection API 중 Proxy 객체 이론을 설명하면 몇백줄이 더 늘 것 같아서,
조금 이따가 보여질 코드에서 이를 펼쳐보려 한다.
Annotation 이 보여질 형태는 다음과 비슷할 것이다.
@MyComponent
public class ... { ... }
// or
@MyComponent
public class ... {
public ... (@MyLazy ...) {
...
}
}
// or
@MyComponent
@MyProxy(ExecutionTime.class)
public class ... {...}
이러한 방식으로 만들 것이다.
Java 의 내장 Proxy 사용법은 조금 배워야 하기 때문에,
먼저
- Spring Container 클래스 제작
- Annotation 제작
HashMap,Queue,Tracker(custom) 자료구조를 이용한 실제 의존성 해결 논리 제작- Proxy 전용 Annotation 제작
- Proxy API 를 전담하는 클래스 제작
이러한 과정으로 흘러 갈 것이다.
코드 구현 - 한 단계씩 구현
➜ test-1 tree -L 3
. (test-1)
├── com
│ └── damsoon
│ ├── Main.java
│ ├── annotation
│ ├── func # --> 삭제하기
│ └── test # --> 삭제하기
├── compile-and-execute.sh
├── result
│ └── bin
│ └── com
└── sources.txt
9 directories, 4 files
이전에 사용할 기능을 테스트하기 위해 만들었던 이 프로젝트 폴더를 그대로 활용 할 것이다.
com.damsoon.Main 에서 로직이 시작 할 것이며,
하위 패키지 폴더에 여러 유틸성 도구와 자료구조가 작성될 것이다.
먼저 위의 com.damsoon.xx 중에, Main.java, annotation 패키지 폴더 빼고
나머지 하위 패키지 폴더를 삭제한다.
그리고, Container 가 생성될 container 패키지 폴더를 생성하고,
이 패키지 폴더에 CustomContainer 클래스를 제작할 것이다.
먼저, 단일 인스턴스 객체를 저장하게 될 Container 를 만들어 보자.
- 컨테이너는 애너테이션으로 작성된 모든 클래스의 "생성자"(
Constructor) 를 보관한다. - 생성자와 인스턴스는 모두
HashMap으로 보관한다. (각 자료는 따로 보관)
Container Class
com.damsoon.container.CustomContainer.java :
package com.damsoon.container;
import java.lang.reflect.Constructor;
import java.util.HashMap;
import java.util.Map;
public class CustomContainer {
private Map<String, Constructor> constructorMap;
private Map<String, Object> instanceMap;
public CustomContainer (){
constructorMap = new HashMap<>();
instanceMap = new HashMap<>();
}
public Map<String, Constructor> getConstructorSet() {
return this.constructorMap;
}
public boolean addConstructor(String fullPackageName, Constructor constructor) {
Object alreadyContain = this.constructorMap.get(fullPackageName);
if(alreadyContain != null) {
System.out.println(
getClass().getSimpleName()
+ " - [Constructor] : "
+ constructor.getName()
+ " 이 이미 존재합니다."
);
}
this.constructorMap.put(fullPackageName, constructor);
return alreadyContain == null ? true : false;
}
public Map<String, Object> getInstanceSet() {
return this.instanceMap;
}
public boolean addInstance(String fullPackageName, Object object) {
Object alreadyContain = this.instanceMap.get(fullPackageName);
if(alreadyContain != null) {
System.out.println(
getClass().getSimpleName()
+ " - [Instance] : "
+ object.getClass().getSimpleName()
+ " 이 이미 존재합니다."
);
}
this.instanceMap.put(fullPackageName, object);
return alreadyContain == null ? true : false;
}
}
간단히 생성자로 내부 자료구를 먼저 생성하고,
"가져가는 것은 쉽되, 추가하는 것은 메서드로" 접근하도록 만들었다. (기능적으로 분리하기 위함)
그리고 HashMap 특성상 중복 추가도 에러가 나지는 않지만,
특정 부분에서 놓친 알고리즘이 무한 반복 할 수도 있기 때문에,
이미 존재 할 경우 경고 출력을 하도록 만들었다.
(Container 는 정말 "담는 것만" 목적으로 하도록 만들었습니다.)
ResearchPackage Class
단순히 Main 에서 시작하고, 하위에 패키지 경로, 파일들이 존재한다고
마법처럼 인식시켜주지 않는다.
즉, File I/O 기능과, Reflection API 를 이용하여
재귀적으로 하위 파일을 탐색하며, 이들을 메타데이터화 시켜 저장해야 한다.
즉, 파일을 찾으면, Class<?> 형태로 변환시켜 List 에 저장한다.
여기서 알고리즘 중 BFS, DFS 방식을 선택하여 재귀 탐색을 실행하면 되는데,
나는 DFS (Stack) 방식을 이용하여 탐색을 할 것이다.
실행되는 위치는 com.damsoon.Main.class 이다.
즉, Main 클래스에서 실행되어 현재 위치, 그리고 재귀적인 패키지 위치와 더불어
모든 .class 파일을 탐색하여 @Container 표식이 새겨진 모든 클래스들을 찾는 것이다.
솔직히 말하자면, 탐색 과정이 생각보다 복잡합니다.
문제는 Directory 가 따로 있는 것은 아니고, File 이라는 클래스에 "디렉토리" 또한 들어있다.
여기서 단순히 클래스를 로드하는 것 뿐만이 아닌, URL 이라는 객체를 추출하는 역할 또한
ClassLoader 가 수행하는 것을 볼 수 있을 것이다.
ResearchPackage :
package com.damsoon.util;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
public class ResearchPackage {
// 클래스 메타데이터 배열 저장.
List<Class<?>> clazzList = new ArrayList<>();
// Main 클래스를 품는 패키지 이름 (com.damsoon)
String rootPackage;
// 시작 루트 패키지 기입 강제
public ResearchPackage(String rootPackage) {
this.rootPackage = rootPackage;
}
// 로직 완료 후 추출
public List<Class<?>> getClazzList() {
return this.clazzList;
}
// 외부에서 시작 제어
public void startScan() throws IOException, ClassNotFoundException {
// 시작 루트 패키지 미기입시 시작하지 않는다. (생성자 단 에서 설정 강제)
if(rootPackage == null) {
System.out.println("시작 패키지를 정하지 않았습니다.");
return;
}
// 파일 경로 --> URL --> File --> 디렉토리 or 클래스 파일
// 이로 인해 기존 루트 패키지 이름을 "com.damsoon" --> "com/damsoon" 으로 변경
String path = this.rootPackage.replace('.', '/');
// 파일, 패키지 리소스를 추출할 ClassLoader 가져오기
ClassLoader loader = Thread.currentThread().getContextClassLoader();
// 현재 패키지 실제 경로를 기준으로 파일, 패키지, JAR 파일을 모두 URL 로 추출한다. (JAR 은 현재 없다 가정)
Enumeration<URL> urlResources = loader.getResources(path);
// "시작 할 때만" URL iter 을 기준으로 파일로 치환하여 재귀한다.
while(urlResources.hasMoreElements()) {
URL rootURL = urlResources.nextElement();
// URL 경로를 기반으로 File 객체를 생성. --> File 로 디렉토리인지, 파일인지 구별 및 클래스 메타데이터 추출이 가능하다.
File rootFile = new File(rootURL.getFile());
if(rootFile.isDirectory()) {
scan(rootFile, this.rootPackage);
}
}
}
private void scan(File file, String packageName) throws ClassNotFoundException, IOException {
System.out.println("scan start");
// 하나의 파일(.class) 이건, 디렉토리이건 동일한 File[] 정보를 가져온다.
File[] tmpFiles = file.listFiles();
// 파일이 없으면 null 이 된다.
if(tmpFiles == null) {
return;
}
// 하나 혹은 그 이상의 파일 혹은 디렉토리들
for (File eachFile : tmpFiles) {
// 현재 파일은 "디렉토리" 일 때,
if(eachFile.isDirectory()) {
// File 리소스와 패키지 이름 + 현재 파일 이름을 재귀로 넘긴다.
scan(eachFile, packageName + "." + eachFile.getName());
} else if(eachFile.getName().endsWith(".class")) { // 만약 디렉토리가 아닌 .class 파일이라면
// 맨 뒤의 protocol 은 없애주고 등록한다. (패키지명으로)
String className = packageName
+ '.'
+ eachFile.getName().replace(".class", "");
// classList 배열에 등록
System.out.println(className);
this.clazzList.add(Class.forName(className));
}
}
}
}
위의 ResearchPackage 클래스는,
Main 을 감싸는 패키지 이름으로부터 시작하여,
DFS 방식으로 재귀적으로 탐색한다.
Main.class :
package com.damsoon;
import com.damsoon.util.ResearchPackage;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.List;
public class Main {
public static void main(String[] args) throws Exception {
// "com.damsoon" 문자열이 들어가게 된다.
ResearchPackage testResearch = new ResearchPackage(Main.class.getPackageName());
// "com.damsoon"
System.out.println(Main.class.getPackageName());
testResearch.startScan();
List<Class<?>> clazzList = testResearch.getClazzList();
System.out.println("메타데이터 확인 절차 시작");
for(int i = 0; i < clazzList.size(); i++) {
Class<?> tmpClazz = clazzList.get(i);
String tmpPackageName = tmpClazz.getPackageName();
String tmpFileName = tmpClazz.getSimpleName();
System.out.println(tmpPackageName + " -- " + tmpFileName);
}
}
}
# 컴파일 전 삭제된 구조나 파일이 존재한다면, 없애야 하기 때문.
➜ test-1 rm -rf result
# Shell Script 실행
➜ test-1 ./compile-and-execute.sh
컴파일 & 실행 구문 시작
com.damsoon # Main -> ResearchPackage 클래스에 전달될 rootPackage 문자열.
scan start # ResearchPackage.scan() 재귀 시작
scan start # .... 재귀 시작
com.damsoon.util.ResearchPackage # 해당 클래스 파일 찾음 -> 메타데이터 리스트 추가
scan start # ... 재귀 시작
com.damsoon.container.CustomContainer # 해당 클래스 파일 찾음 -> 메타데이터 리스트 추가
com.damsoon.Main # 해당 클래스 파일 찾음 -> 메타데이터 리스트 추가
메타데이터 확인 절차 시작 # ResearchPackage.getClazzList() 이후. - Main 에서 실행
com.damsoon.util -- ResearchPackage
com.damsoon.container -- CustomContainer
com.damsoon -- Main
컴파일 & 실행 구문 종료
# 컴파일 이후의 모습
➜ result tree
.
└── bin
└── com
└── damsoon
├── Main.class
├── container
│ └── CustomContainer.class
└── util
└── ResearchPackage.class
6 directories, 3 files
현재는 모든 .class 파일을 읽어서 배열에 넣는 상황인데,
곧 만들게 될 애너테이션과 더불어 @MyComponent 가 붙은 객체들만 가져오게 수정할 것이다.
본격적으로 의존성 해결 전문 클래스를 만들기 전 "요약"
커스텀 Spring Container 를 만들기 위해, 수많은 선 지식들을 작성했으며,
이를 구현하기 위한 요구사항, 그리고 전반적인 로직을 구현했다.
유일 인스턴스(단일 객체), 의존성 해결, 순환 참조 해결
이 3 가지를 염두한 컨테이너를 제작하는 중이다.
그러나 "의존성 해결" 부분에서 "어떤 방법론" 을 사용할 것인지 결정해야 했다.
- 모든 객체를 프록시 객체로 만들고 서로 참조하면 된다.
- Spring 도 사용하는
CGLIB라이브러리를 사용하면 된다. - 의존성 해결 부분을 나만의 로직으로 직접 해결한다.
나는 포텐셜을 높이기 위해 과감히 "3번" 을 선택했다.
당연히 이 길은 꽃밭은 아니고 가시밭길과 레고지뢰가 가득한 길이었다...
먼저 간단히, CustomContainer 클래스 구현을 통해,
- 클래스의
Constructor을 보관하는 Map - 클래스의 인스턴스,
Object를 보관하는 Map
을 가지고 있다.
두 맵의 key 는 패키지명 + 파일 이 붙어있는 문자열 형태이다.
(EX - "com.damsoon.component.Component1")
추후, 단일 객체의 클래스 메타데이터를 뽑아서 사용하기 위한 형태이다.
CustomContainer 는 일단 아주 단순한 형태의 Spring Container 형태가 될 것이다.
ResearchPackage 클래스는 전달된 부모 패키지 경로부터 시작하여
.class 파일들을 읽어 메타데이터로 전환한다.
우리가 작성하는 코드는 .java 지만, 실제 작동시 .class 라는 것을 명심해야 한다.
파일 탐색은 DFS 재귀 메서드 형식으로 제작되었다.
다만 현재는 "모든" 클래스 파일을 로드하는 중이며,
Annotation 인터페이스도 제작된다면, 애너테이션을 읽고 특정 컴포넌트만 읽게 수정 될 것이다.
ResolveDependency(의존성 해소) 클래스를 제작하는 데 중요하는 것은
Class<?> 배열을 받고 의존성을 해소하는 것 뿐만 아니라, 순환 참조를 예방해야 한다는 것이다.
순환 참조 이후 생성자에서 서로를 실행하는 것은 "애초에 완벽하게 틀린 Logic" 에 해당한다.
"문법으로서 틀리진 않지만, 논리적으로 틀리다"
A <--> B 라는 상황은 틀리진 않다.
그러나, A 와 B. 서로의 컴포넌트가 생성되기 위하여 서로를 필요로 하는 상황은
논리적으로 절대로 성립될 수 없다.
그러나, A 와 B 서로를 사용한다는 것 자체가 틀리진 않다.
중요한 것은 "생성자"(Constructor) 부분에서 서로를 실행해서는 안된다는 것이다.
간단하게 설명한다면,
- 빛이 있기에 어둠이 존재한다.
- 어둠이 있기에 빛이 존재한다.
두 개념 중 어떤 것이 맞을까?
논리적으로 2 가지 모두 성립할 수 없게 된다.
Annotation Interface 선언하기
스프링의 완성도가 높고 간편하기 때문에 Annotation 이 "특정한 행동" 을 한다고 착각하기 쉽지만,
실제로는 이 애너테이션을 읽고 클래스 객체를 load 하는 프로그램이 "특정한 행동을 한다".
Annotation 을 통해 총 3 개의 기능을 적용 할 것인데,
- 컨테이너에 들어갈 컴포넌트 표시
- 순환 참조됨을 표시
- 어떤 클래스를 Proxy 로 사용 할 것인지 "객체 메타데이터" 형태로 전달 할 것이다.
여기서 1 번은 MyComponent 라는 이름을 가지게 되고,
2 번은 MyLazy 라는 이름으로 참조 객체에 선언하게 된다.
단, 생성자의 "인자" 에 붙일 수 있다.
3 번은 MyProxy 라는 애너테이션으로 만들 것이다.
MyProxy 는 객체 위에 선언되나,
타겟 클래스 내부의 "모든 메서드" 에 대해서 적용한다.
MyComponent
package com.damsoon.annotation;
import java.lang.annotation.*;
@Documented
// JVM 구동 시 클래스를 읽어 메타데이터로 만들어야 하므로.
@Retention(RetentionPolicy.RUNTIME)
// 어떠한 종류의 클래스에도 적용할 수 있어야 하므로.
@Target(ElementType.TYPE)
public @interface MyComponent {
// 스프링 컨테이너의 객체 중 "유일 객체" 임을 표식하므로, 따로 값을 넣진 않는다.
}
MyLazy
package com.damsoon.annotation;
import java.lang.annotation.*;
@Documented
// JVM 구동 이후 의존성을 해소하므로.
@Retention(RetentionPolicy.RUNTIME)
// 순환 참조라는 것을 생성자의 인자 단계에서 표식하기 위함
@Target(ElementType.PARAMETER)
public @interface MyLazy {
}
MyProxy
package com.damsoon.annotation;
import java.lang.annotation.*;
@Documented
// JVM 에서 인스턴스 생성 이후 Proxy 를 적용 해 주어야 하기 때문.
@Retention(RetentionPolicy.RUNTIME)
// 객체를 타겟으로 하되, 해당 객체들의 Method 를 타겟으로 Proxy 를 행할 것이다.
@Target(ElementType.TYPE)
public @interface MyProxy {
// Proxy 역할을 할 메서드를 가지고 있는 클래스 메타데이터를 가져야 한다.
Class<?> handler();
Class<?> targetInterface();
}
MyProxies: 단일 컴포넌트에 여러 AOP, 즉 Proxy 가 적용 될 경우.
package com.damsoon.annotation;
import java.lang.annotation.*;
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface MyProxies {
Class<?>[] proxies();
Class<?> targetInterface();
}
위의 4 애너테이션은 전부 JVM 런타임 중, 클래스 메타데이터로 치환되어 load 되어야 하기 때문에,
전부 RetentionPolicy.RUNTIME 을 가지게 된다. (Retention : "유지" 라는 의미)
이제 이 애너테이션 메타데이터를 내가 만든 클래스 중 어떤 단계에서 검증할 것인지 정해야 한다.
그 단계를 바로 ResolveDependency Class 에서 진행 할 것이다.
이 클래스를 제작하기 전에, 먼저 애너테이션에 대한 정보가 올바르게 추출 될 수 있는지
ResearchPackage 클래스에서 확인을 해 보자.
Annotation 메타데이터 논리의 검증
먼저, ResearchPackage 는 Main.class 를 포함하는 패키지에서
재귀적으로 하위 패키지를 뒤지며 "모든 클래스" 를 가지고 온다.
즉, 아직 @MyComponent 와 같은 애너테이션이 붙은 클래스 메타데이터를 가져오는 것이 아니라,
일단 모든 클래스 메타데이터를 가져오고 있는 상황이다.
이러한 상황에서 ResearchPackage 메서드를 통해
"애너테이션이 없는" 클래스와, "애너테이션이 있는" 클래스의 메타데이터는 어떻게 다른지
이를 확인 해 보고 넘어가는 것이 매우 중요하다고 판단했다.
현재 디렉토리의 현황은 이러하다.
➜ com tree
.
└── damsoon
├── Main.java
├── annotation # 애너테이션 구현 패키지
│ ├── MyComponent.java
│ ├── MyLazy.java
│ └── MyProxy.java
│ └── MyProxies.java
├── component # 애너테이션 정보를 테스팅 할 컴포넌트들
│ ├── Component1.java
│ ├── Component2.java
│ └── Component3.java
├── container
│ └── CustomContainer.java
├── proxy # @MyProxy 를 테스팅 하기 위한 클래스 - 아무 정보 없음
│ └── ProxyTest.java
└── util
├── ResearchPackage.java
└── ResolveDependency.java
7 directories, 12 files
ProxyTest 는 클래스만 생성 해 놓고, 내부에는 어떠한 장치도 마련하지 않았다. (아직은)
우선, component 패키지에 생성 한 테스팅용 컴포넌트 3 개를 예시로 마련했다.
// Component1
package com.damsoon.component;
import com.damsoon.annotation.MyComponent;
@MyComponent
public class Component1 {
Component2 component2;
// 순환 참조 상태. Component2 에서 @MyLazy 선언을 해 준 상태.
public Component1 (Component2 component2) {
this.component2 = component2;
}
}
// Component2
package com.damsoon.component;
import com.damsoon.annotation.MyComponent;
import com.damsoon.annotation.MyLazy;
@MyComponent
public class Component2 {
Component1 component1;
public Component2 (@MyLazy Component1 component1) {
this.component1 = component1;
}
}
// Component3
package com.damsoon.component;
import com.damsoon.annotation.MyAutowired;
import com.damsoon.annotation.MyComponent;
import com.damsoon.annotation.MyProxy;
import com.damsoon.proxy.ExecutionTime;
// MyProxy 에 메타데이터로 전달되며,
// 해당 인터페이스에 존재하는 Method 에만 Proxy 를 적용한다.
// 즉, "testProxy()" 메서드만 Proxy 가 적용된다.
interface Component3 {
public void testProxy();
}
// 여러 애너테이션이 붙어 있는 상황을 연출
@MyComponent
// 예시로 이 객체에 적용될 Proxy 로직과, 타겟 인터페이스를 메타데이터로 정한다.
@MyProxy(handler = ExecutionTime.class, targetInterface = Component3.class)
// implements Component3 무조건 적용.
public class Component3Impl implements Component3 {
Component4 component4;
@MyAutowired
public Component3Impl(Component4 component4) {
this.component4 = component4;
}
public void testProxy() {
System.out.println("Method in Component3");
}
}
그리고, 모든 클래스 메타데이터를 load 하는 ResearchPackage 클래스에서
.class 파일을 Load 할 때 마다, 해당 클래스가 "어떤 애너테이션을 가지고 있는지"
확인하는 콘솔 출력 Logic 을 넣어보자.
ResearchPackage :
package com.damsoon.util;
import java.io.File;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.List;
public class ResearchPackage {
// 클래스 메타데이터 배열 저장.
List<Class<?>> clazzList = new ArrayList<>();
//...
private void scan(File file, String packageName) throws ClassNotFoundException, IOException {
System.out.println("scan start");
// ...
for (File eachFile : tmpFiles) {
// 현재 파일은 "디렉토리" 일 때,
if(eachFile.isDirectory()) {
scan(eachFile, packageName + "." + eachFile.getName());
} else if(eachFile.getName().endsWith(".class")) {
Class<?> clazzData = Class.forName(className);
//
// 클래스 메타데이터에서 애너테이션 정보를 꺼내기
//
Annotation[] annotations = clazzData.getAnnotations();
System.out.println(clazzData.getName() + " : " + Arrays.toString(annotations));
this.clazzList.add(clazzData);
}
}
}
}
➜ test-1 rm -rf result
➜ test-1 ./compile-and-execute.sh
컴파일 & 실행 구문 시작
...
com.damsoon.proxy.ProxyTest : [] # 이 클래스는 애너테이션이 붙어있지 않음.
...
com.damsoon.util.ResolveDependency : [] # 마찬가지
com.damsoon.util.ResearchPackage : [] # 애너테이션이 없다면 "빈 배열" 이다. (null 아님)
...
# "@com.damsoon.annotation.MyComponent()" 로 출력된다. - 1 개
com.damsoon.component.Component2 : [@com.damsoon.annotation.MyComponent()]
# "객체" 에 적용된 커스텀 애너테이션이 2 개이다. - 2 개
com.damsoon.component.Component3 : [@com.damsoon.annotation.MyComponent(), @com.damsoon.annotation.MyProxy(doProxy=com.damsoon.proxy.ProxyTest.class)]
# 객체에 적용된 커스텀 애너테이션이 1 개 이다. - 1 개
com.damsoon.component.Component1 : [@com.damsoon.annotation.MyComponent()]
...
## 여기서부턴 애너테이션을 구성하는 "기본 내장 애너테이션" 을 추출한다.
com.damsoon.annotation.MyLazy : [@java.lang.annotation.Documented(), @java.lang.annotation.Retention(RUNTIME), @java.lang.annotation.Target({PARAMETER})]
com.damsoon.annotation.MyComponent : [@java.lang.annotation.Documented(), @java.lang.annotation.Retention(RUNTIME), @java.lang.annotation.Target({TYPE})]
com.damsoon.annotation.MyProxy : [@java.lang.annotation.Documented(), @java.lang.annotation.Retention(RUNTIME), @java.lang.annotation.Target({TYPE})]
## 애너테이션 끝.
...
## 밑의 2 개의 클래스도 애너테이션이 붙지 않음.
com.damsoon.container.CustomContainer : []
com.damsoon.Main : []
메타데이터 확인 절차 시작 # Main 에서 실행 및 검증.
com.damsoon.proxy -- ExecutionTime
com.damsoon.util -- ResolveDependency
com.damsoon.util -- ResearchPackage
com.damsoon.component -- Component2
com.damsoon.component -- Component3
com.damsoon.component -- Component1
com.damsoon.annotation -- MyLazy
com.damsoon.annotation -- MyComponent
com.damsoon.annotation -- MyProxy
com.damsoon.annotation -- MyProxies
com.damsoon.container -- CustomContainer
com.damsoon -- Main
컴파일 & 실행 구문 종료
파일의 재구성과 신기능 도입에 대한 검증이 길다고 생각할 수 있겠지만,
"애너테이션 추출 및 분석" 단계를 넘어가서 곧바로 "사용" 단계에 진입하면,
추후 일어날 수 있는 에러를 감당하지 못할 수 있다. (Technical Dept.)
확실히 애너테이션을 잘 적용하고, 추출도 하고 있는 것을 볼 수 있다.
현재는 "모든 클래스 메타데이터" 추출 과정을 ResearchPackage 클래스에서 진행하고 있는데,
우리는 클래스 중 @(Annotation) 이 적용된 클래스를 따로 추출해야 한다.
이에 대한 기능을 ResolveDependency 클래스에서 진행 할 것이다.
ResolveDependency Class
위에 작성한 CustomContainer, ResearchPackage 클래스는
각각
CustomContainer: 클래스 자체의 "생성자", 그리고 "메타데이터" 들을 보관한다.ResearchPackage: 직접 루트 패키지 경로에서부터 탐색하여 모든 클래스 메타데이터를 추출한다.
이후CustomContainer에 전달할 예정이다.
그렇다면, ResolveDependency 클래스는 어떤 역할을 하게 될까?
물론, Main 에서 조작하게 되겠지만,
ResearchPackage에서 추출된Class<?>메타데이터 리스트를 대상으로 애너테이션을 분석한다.- 추후 작성될
MyComponent,MyLazy,MyProxy에 대해
어떤 로직을 추가할 지 내부에 구현한다. CustomContainer에Constructor,Object(instance) 를 등록한다.- 위의 과정에서 의존성을 해결한다.
마지막에 "의존성을 해결한다" 고 뭉퉁그려서 이야기했지만,
위에서 미리 언질했기 때문인데,
- 모든 생성자에 "일단"
null인자를 주입하여 인스턴스부터 생성 해 놓는다. - 그 다음 인스턴스가 요구했던 모든 인자(값)를 다시 주입하여 완성한다.
이 방식은 "생성자 내부" 에서 의존하는 객체의 컴포넌트 메서드를 호출하지만 않으면
순환 참조를 신경 쓸 필요가 없다.
짧게 말해 순환 참조에 대한 에러 바운더리가 굉장히 넓어진다는 것이다.
ResolveDependency 의 막중한 책임
"의존성 해결" 이라는 부분은 단순히 하나의 클래스로 만들 만한 쉬운 문제는 아니다.
여기서 "알고리즘 역량" 이 굉장히 중요한 이유가 드러나는데,
- 책임의 분리
- 재귀
- 주어진 데이터를 통한 프로그램의 자동화
- 등등..
복잡한 개념이 한데 뭉쳐 ResolveDependency 를 구성하기 때문이다.
"또한 메타데이터 프레임워크를 만드는 것은 이번이 처음이기에 더 어려운 것도 있다."
객체가 원하는 의존성을 어떻게 주입 할 것인가?
나는 Spring 과는 정 반대의 과정을 선택하기로 결정했었다.
- Spring : (선 의존성 완성 후 컨테이너)
- 나 : (선 컨테이너 완성 후 의존성 주입)
Spring 의 방식
스프링은 사용자의 코드에 최대한 제약을 두지 않고, 편리하게 사용하도록
CGLIB 라는 외부 라이브러리를 사용했다.
이 라이브러리는 사용자가 선언한 Annotation 에 따라,
.class 파일로 컴파일 하고 나서, 필요한 로직을 ByteCode 로 수정, 혹은 삽입한다.
이 덕분에 Java 에서 요구하는 언어적 제약에서 벗어나 매우 유연한 프레임워크가 되었다.
또한, 순환 참조를 허용하는 방식은 @Lazy 로 허용되기에,
이를 사용하지 않고 순환 참조를 선언한다면, 에러를 뱉으며 그대로 프로그램이 종료된다.
이는 어떻게 보면 개발자에게 올바른 코드의 방향성을 제공하기도 한다.
스프링이 순환 참조를 허용하지 못하는 이유에는 또 다른 이유도 있는데,
스프링은 한 컴포넌트의 "생성자 인자" 를 전부 읽는데, 이 의존성 해결 방식이
완전한 DFS 이며, 한 의존성을 완성하기 위해 수많은 재귀 속에서 의존성이 완성되지 못했다면,
그 하나의 의존성으로 인해 프로그램은 실행되지 않는다.
EX - 간단한 코드를 보기
// ...
@Component
public class Component1 {
private Component2 component2;
private Component3 component3;
public Component1(Component2 component2, Component3 component3) {
this.component2 = component2;
this.component3 = component3;
}
// ...
}
@Component
public class Component2 {}
@Component
public class Component3 {
private Component4 component4;
public Component3 (Component4 component4) {
this.component4;
}
// ...
}
@Component
public class Component4 {
private Component1 component1;
public Component4 (Component1 component1) {
this.component1 = component1;
}
}
스프링에서 위와 같은 컴포넌트가 존재한다고 가정한다.
Spring 이 Component1 메타데이터를 읽으면,
Componenent2, Component3 의존성을 원한다고 인식 할 것이다.
그런데, 여기서 문제가 생긴다.
Component2 에 대한 인스턴스를 생성했는데,
Component3 에 대한 의존성을 완성하기 위해,
Component3 메타데이터를 읽고, 생성자를 실행한다.
Component3 를 완성하기 위해 Component4 메타데이터를 읽고, 생성자를 실행한다.
그런데, Component4 는 Component1 을 필요로 한다.
"결국 도돌이표(순환참조)" 가 되는 것이다.
위의 과정처럼 DFS 방식으로 끝까지 메서드가 의존성을 완수하기 위해 추적했지만,
결국 도돌이표이자, "완성되지 못하는" Component1 을 보니,
Spring 은 Component1 을 완성하지 못하고 프로그램을 종료한다.
즉, Spring 이 의존성을 해결하는 방식은,
하나의 컴포넌트 메타데이터를 발견했을 때,
이를 "그 자리에서 모두 해결한다" 는 방식인 것이다.
내가 만든 의존성 해결의 방식
나는 그러한 생각이 들었다. "없는 컴포넌트를 개발자가 부르지는 않는다."
그러니까, 어쨌든 순환참조가 발생할 구조더라도, 코드 컴파일러 수준에서
에러가 날 수준의 로직이 아니라면, 순환 참조는 스파게티 코드가 되더라도 문제가 되지는 않는다는
그런 생각이 들었다. (물론, 이러한 프로젝트 Architecture 가 너무 복잡해지지만.)
따라서, 나의 방식은 순환참조를 허용하되, 이에 대한 경고는 하고 넘어가는 방식을 만들기로 결정했다.
위에서 그런 말을 간단히 했었다.
"빛이 있기에 어둠이 있는가? 어둠이 있기에 빛이 있는가?"
이는 내가 생각한 순환참조를 의미할 수 있는 가장 알맞는 말이라고 생각한다.
나는 "꼼수" 를 썼다.
결국에 둘 다 존재해야 하는데, 서로가 서로의 존재가 없어서 생성 될 수 없다면,
밑의 방식 중 하나를 선택하면 된다.
- "빛" 에게 "가짜 어둠" 을 선사한다.
- "어둠" 에게 "가짜 빛" 을 선사한다.
둘 중 하나만 이루어져도 순환 참조는 해결된다.
(위의 방식은 Spring 에서 Lazy 를 적용하는 방식이기도 하다.)
"빛" 그리고 "어둠" 에 관한 상황은 프로젝트를 구성하는 데에 자주 발생할 수 있는 문제이다.
그래서, 나는 "모든 존재가 탄생하는 데에 필요한 재료를 가짜로 준다."
그렇다면, 나의 프로젝트를 구성하는 데 필요한 모든 "존재" 들은,
자신들이 "완성되었다." 라고 "속는다."
(여기서 말하는 재료는 null 값을 의미한다.)
모든 컴포넌트들은 속았지만, 결국 모두 완성되었다.
따라서, 컴포넌트들을 순회하며, 필요한 의존성 데이터를 읽고,
컴포넌트에 의존성들을 모두 꽂아주면, "완성된 객체" 가 된다.
의존성을 해결하기 위해 필요한 나의 창의성이었다.
나의 방식은 창의적이지만, 그만큼 위험하다.
의존적인 컴포넌트가 "완성" 되어야지만 채울 수 있는 방식은
결국 Spring 이나 나의 방식이나 동일하다.
가장 중요한 것은, 개발자가 자신이 "스파게티 코드" 를 작성했는지 꼭 알아야 한다는 것이다.
그것을 알려주지 못한다면, 실제로 나의 프로그램을 사용한 모든 사용자들은,
필연적으로 스파게티 코드를 짜게 된다. 이것이 순환참조 구조인지, 등등..
결국 프레임워크라는 프로그램의 "본질" 은, 개발자의 개발을 편리하게 만들어 줄 뿐만 아니라,
"개발 방향의 의도" 까지도 고려해야 한다는 것이다.
순환 참조가 발생한다는 것은 컴포넌트 의존성 아키텍쳐를 바꿔야 한다는 의미이다.
개발자의 실력 이슈, 혹은 어쩔 수 없는 상황에서 사용 할 때, 어떤 컴포넌트들이 결국
정상적으로 의존성을 해결하지 않았다는 것을 알려주어야 한다는 것이다.
그리고, 모드를 선택하여 개발자가 스파게티 코드를 허용할지, 금지할지 결정해야 한다.
프록시 적용의 문제점
Java 의 프록시 적용에 대해서 알게 된다면,
왜 Spring 이 CGLIB 라는 라이브러리를 이용하는지 알게 된다.
물론, Spring 을 사용 할 때, interface 가 매우 권장된다.
그러한 구조를 가지고 있기도 하고,
심지어는 단순히 "컴포넌트 클래스" 자체에 @Proxy(...) 와 같은 방식으로
간단히 클래스 인스턴스에 프록시를 적용할 수 있다는 것이다.
"원래 Java 의 프록시는 Spring 처럼 쉽게 적용 할 수 없다."
프록시를 적용하기 위해서는,
InvocationHandler 인터페이스를 적용한 AOP 클래스(유일 객체 아님)
그리고 기능을 적용할 컴포넌트 클래스 객체를 "위의 AOP 클래스" 의 생성자로 등록.(주소 등록)
이 컴포넌트 클래스의 "어떤 메서드" 를 프록시 적용 할 것인지 인터페이스 메서드로 선언
그리고 Proxy 유틸 객체 내장 메서드에 인자로,
- 컴포넌트 메타데이터를 가져온 스레드의 "클래스로더"
- 컴포넌트 인스턴스에 적용될 메서드들을 담은 "인터페이스들"(
Class<?>[]타입) InvocationHandler를 적용한 AOP 클래스
가 들어간다.
복잡하고 불편하지만, 기능의 분리는 준수한 메서드이다.
그러나, Spring 을 생각해보자.
단순히 Component Class 위에다, 애너테이션 @ 하나로 AOP 기능이나, 프록시를 적용할 수 있다.
왜 그럴까?
CGLIB 의 바이트코드 수정 기술 때문이다.
Spring 에서는 @Aspect 라는 애너테이션과 함께 작성된 Bean 은,
특정 문법을 통해 "지정한 패키지를 포함한 모든 컴포넌트" 에 지정된 AOP 를 실행한다.
그리고 단순히 컴포넌트만 존재하던 .java 파일이 있고, 이것이 AOP 에 적용되었다 가정하면,
원래 파일 :
@Component
public class Component1 {
@ProxyTarget // 이러한 AOP 클래스("ProxyTarget")가 있다고 "가정" 해보자.
public void test_1() {
...
}
}
이러한 아주 간단한 컴포넌트가 있는데, AOP Bean 의 타겟에 걸린 것이다.
그렇다면, 컴파일 이후, JVM 에서 메모리에 load 까지 된다면, 이러한 형태가 된다.
public class Component1$$CGLIB.....$$31412 extends Component1 {
.....
@Override
public void test_1() {
// AOP 로직
super.test_1();
// AOP 로직
}
}
위의 정보는 xxx.xxx.Component1 의 정보로 "의존성 주입" 한다.
이는 CGLIB 로 가능한 작업이다. (기본 기능으로는 어림도 없고, 바이트코드를 만져야 가능함.)
언뜻 보기에, 결과물이 굉장히 복잡해 보이고, 왜 저렇게까지 해야 하는지 모를 것이다.
하지만, 이는 Spring 개발자들이 최대한 Proxy, AOP 기능을 "쉽게" 넣기 위해 노력한 결과물이다.
생각해보자. 만약 스프링이 이러한 기능을 만들어 두지 않았다면,
InvocationHandler를 구현한 빈, 혹은 날 것의 클래스를 "직접 구현"- 적용될 메서드의 "이름" 을 전부 AOP 클래스에 작성 및 Logic 구현
- 적용될 컴포넌트의 메서드들을 정하기 위해, "타겟 인터페이스" 작성
- 프록시 객체의 생성을 위해 위에서 언급한 3 가지의 인자를 전부 가져오기.
이걸 수작업으로 진행해야 한다. 이 과정도 Java 내장 Proxy 기능 덕분에 "축소" 된 것이다.
Spring 이 @Aspect 라는 기능을 만들어 특정 패키지, 혹은 단일 클래스에 적용할 수 있도록
문자열로 정하도록 만들어 두었지만, 편리함이 느껴지며 동시에 괴기함까지 느껴진다.
편의성을 위해 우리가 선언하는 과정과, 프록시가 실제로 적용되는 과정에서 괴리감이 발생하기 때문이다.
그러나 이는 사용자의 편의성을 위함이다.
스프링은 왜 프록시 적용 결과를 괴기하게 만들었을까?
Spring 에서 프록시가 적용된 컴포넌트 코드와, 컴파일 "직후" 의 컴포넌트 코드는
사실 거의 동일하다.(애너테이션이 유지됨 - RetentionPolicy.RUNTIME 덕분)
그러나, 나는 위의 예시에서 "JVM 런타임 이후" 까지 컴파일된 .class 의 예시를 보여주었다.
개인적인 의견으로 처음 보았을 때, 억지스럽게 적용한다고 판단했다.
그리고 Java 의 Proxy 에 대해서 배우고 이해했을 때, Spring 의 의도를 정확히 파악했다.
위에서 아주 짧게 언급 한 적이 있는데,
프록시가 적용될 Target 인스턴스 와,
프록시가 적용"된" Result 인스턴스 는,
"단 하나" 의 공통점을 제외하고 모든 점에서 차이점이 존재한다.
이를 나열 해 보겠다. (프로젝트를 하면서 알게 된 점들)
- 공통점
- 적용 인터페이스가 동일하다.
- 차이점
- 원본 인스턴스에 선언된 "필드 변수들" 이 없다.
- 인터페이스에 포함되지 않은 메서드는 프록시 객체에 존재하지 않는다.
- 인스턴스 원본 주소와 "완전히" 다르다. (그냥 다른 객체이다)
위와 같은 차이점이 있지만, "적용 interface" 가 동일하다는 점이 있다.
이 덕분에 나는 "순수 Java 와 내장 LIB 사용" 이라는 제약에도 불구하고
프록시를 적용 할 수 있었다. (이후에 API 사용법을 보게 됩니다. 지금 작성하면 어지러울 수도..)
그러나, Spring 은 다른 방식을 채택했다.
일단 Spring 에서는 딱히 인터페이스를 작성하지 않아도 적용이 된다.
이게 어떻게 가능했을까??
주소가 아무리 다르고, 변수가 없고... 그렇더라도,
원본 객체와 프록시 객체가 동일할 수 있는 조건은, 바로 "Type" 이다.
잠시 생각 해 보자. "다른 주소를 가지지만 Type" 을 동일시 할 수 있는 방법은?
- 동일한 Interface 를 구현한다. --> 이건 순수 Java 방식
- 타겟 Instance 를 "계승"(extends) 한 객체 --> Spring 의 CGLIB 방식
바로 2 번 방식을 사용하여 Type 을 맞춘 것이다.
그게 비록 순수 Java 스펙에 맞춰지지 않고, 바이트코드를 RUNTIME 중에 변경하더라도,
프레임워크 사용자가 거의 유일하게 객체 단에서 애너테이션을 선언하는 것 만으로도,
프록시를 적용할 수 있는 방식이기 때문이다.
그러나 여기서 순수 객체 이름이 괴기한 이유는, 내부에서 "고유"(Unique) 한 이름을 가져야 하기 때문이다.
너무 많은 설명과 비유로 혼란을 조장한 것 처럼 보이지만,
앞으로 나올 코드를 처음 민낯으로 보는 것이 더 혼란을 조장할 것 같아서,
글 자체로서 설명하는 것을 양해 부탁 드립니다.
프로젝트에 적용할 Custom Annotation 들
@Documented
// JVM 구동 시 클래스를 읽어 메타데이터로 만들어야 하므로.
@Retention(RetentionPolicy.RUNTIME)
// 어떠한 종류의 클래스에도 적용할 수 있어야 하므로.
@Target(ElementType.TYPE)
public @interface MyComponent {
// 스프링 컨테이너의 객체 중 "유일 객체" 임을 표식하므로, 따로 값을 넣진 않는다.
}
// ---
@Documented
@Retention(RetentionPolicy.RUNTIME)
// 컴포넌트의 필드 변수, 혹은 의존성 지정 생성자에 선언한다.
@Target({ElementType.FIELD, ElementType.CONSTRUCTOR})
public @interface MyAutowired {
}
// ---
@Documented
// JVM 구동 이후 의존성을 해소하므로.
@Retention(RetentionPolicy.RUNTIME)
// 순환 참조라는 것을 생성자의 인자 단계에서 표식하기 위함
@Target(ElementType.PARAMETER)
public @interface MyLazy {
}
// ---
@Documented
// JVM 에서 인스턴스 생성 이후 Proxy 를 적용 해 주어야 하기 때문.
@Retention(RetentionPolicy.RUNTIME)
// 객체를 타겟으로 하되, 해당 객체들의 Method 를 타겟으로 Proxy 를 행할 것이다.
@Target(ElementType.TYPE)
public @interface MyProxy {
// InvocationHandler 가 구현된
Class<? extends InvocationHandler> proxy();
Class<?> targetInterface();
}
// ---
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface MyProxies {
Class<? extends InvocationHandler>[] proxies();
Class<?> targetInterface();
}
Custom Annotation 적용 예시 컴포넌트들
@MyComponent
public class Component1 {
Component2 component2;
// 순환 참조 상태. 그러나 Component2 에서 @MyLazy 선언을 해 준 상태.
@MyAutowired
public Component1 (Component2 component2) {
this.component2 = component2;
}
}
// ---
@MyComponent
public class Component2 {
Component1 component1;
// 필드 변수로 "MyAutowired" 한 상태.
@MyAutowired
Component3 component3;
@MyAutowired
// @MyLazy 선언은 완벽한 순환참조 상태에서,
// 개발자 스스로가 이를 인지하고 있음을 나타내는 신호이다.
public Component2 (@MyLazy Component1 component1) {
this.component1 = component1;
}
}
// ---
// Component3Impl 컴포넌트에서 AOP, proxy 가 적용되야 할 "메서드" 를 선언한다.
interface Component3 {
public void testProxy();
}
@MyComponent
// proxy
// --> 이 컴포넌트 메서드들에 적용하려고 하는 기능.
// --> InvocationHandler 를 implements 한 클래스이다.
@MyProxy(proxy = ExecutionTime.class, targetInterface = Component3.class)
// implements Component3 는 "필수" 이다. targetInterface 선언 일치해야 한다.
public class Component3Impl implements Component3 {
Component4 component4;
@MyAutowired
public Component3Impl(Component4 component4) {
this.component4 = component4;
}
public void testProxy() {
System.out.println("Method in Component3");
}
}
// ---
@MyComponent
public class Component4 {
Component3 component3;
@MyAutowired
public Component4 (@MyLazy Component3 component3) {
this.component3 = component3;
}
}
// ---
// Component5, 6 서로는 "완벽한 순환참조" 형태를 이루고 있다.
// --> @MyLazy 를 선언하지도 않았기 때문이다.
@MyComponent
public class Component5 {
Component6 component6;
@MyAutowired
public Component5(Component6 component6) {
this.component6 = component6;
}
}
// ---
@MyComponent
public class Component6 {
Component5 component5;
public Component6(Component5 component5) {
this.component5 = component5;
}
}
위의 애너테이션 모두 순수 Java 로 만들어졌다.
갑작스럽게 애너테이션을 보여주는 이유는, ResolveDependency 클래스가
애너테이션을 "감지" 하고 "재구성" 하기 때문이다.
클래스 메타데이터는 "Annotation" 메타데이터를 포함한다.
즉, 위의 모든 annotation 들은, 이런 의미이다.
"이 컴포넌트는, 이러한 성질을 가지고, 적절한 변환을 해 주어야 한다."
표식으로서 사용된다. 그리고 이는 Spring 또한 마찬가지이다.
@MyComponent- 만들어질 프로그램에서 사용될 컴포넌트에 선언한다.
- 지정된 패키지 스캔 위치에서 "모든 클래스" 를 읽는데, 여기서 한번 필터링한다.
@MyAutowired- 컴포넌트 내부의 필드 변수들, 혹은 지정 생성자에 선언한다.
- 클래스 자체의 생성자는 여러 개 일 수도 있다. or Default.
- 사용자는 여러 생성자를 선언하고 다른 의존성이 필요 할 때 다른 생성자에 기입할 수 있다.
- 또한 필드 변수에 단일로 붙여 의존성을 넣을 수 있다.
- 여러 필드에 선언할 수 있으나, 여러 생성자에 선언할 수 없게 만들어 놨다.
@MyLazy- 컴포넌트의 생성자 인자, 혹은 필드 변수에 선언된다.
- 개발자가 스스로 선언한 구조가 "순환참조" 임을 자각하고 있다는 신호이기도 하다.
- Lazy 의존성을 해소하는 로직을 따로 구성했다.
@MyProxy- 해당 컴포넌트에 Proxy 기능을 적용하기 위한 애너테이션이다.
- 인자로
InvocationHandler를 구현한 AOP 로직 클래스, - 그리고 이 컴포넌트에서 "어떤 메서드" 에 프록시를 적용할 것인지 지정한다.(interface)
@MyProxies- 해당 컴포넌트에 다중 Proxy 기능을 적용하게 되면 사용하는 애너테이션이다.
- 인자로
InvocationHandler를 구현한 클래스 배열(Class<?>[]) - 그리고 위와 동일하게 interface 로 지정한다.
위의 애너테이션과 클래스 자체의 메타데이터를 분석하여,
싱글톤 인스턴스 컨테이너를 완성하는 것이 목표라고 할 수 있다.
생성자 (Constructor) 와 필드 변수
public class ResolveDependency {
// String : 인스턴스들이 기다리는 클래스 정보의 문자열
// WaitingField : 인스턴스-필드 쌍 배열을 의미하며, 대기중인 리스트를 의미한다.
Map<String, List<WaitingField>> dependencyMap;
// String : 인스턴스들이 기다리는 클래스 정보의 문자열
// WaitingField : 위와 같으나, "@MyLazy" 선언으로 의존성 해결을 한 경우에 한함.
Map<String, List<WaitingField>> lazyMap;
// 의존성이 완료된 인스턴스는 이 "큐"에 들어가고, poll 되며 컨테이너에 넣는다.
// CompleteObject :
// --> 의존 리스트를 해소하기 위해 만들어 둔 자료구조.(문자열, 인스턴스, 클래스 데이터)
// --> 완성 인스턴스는 자기를 기다리는 필드-인스턴스 리스트들을 해소 해 줘야 한다.
Queue<CompleteObject> completeQueue;
// Object : "미완성 인스턴스" 를 의미한다.
// AtomicInteger : 이 인스턴스가 완성되기까지 필요한 수를 의미한다.
// "Integer" 는 불변성을 지녀 AtomicInteger 로 선언하였다.
Map<Object, AtomicInteger> dependencyTracker;
// String : 완성된 인스턴스의 등록 패키지 이름. -> 위와 의미가 동일함.
// Object :
// --> 일반 인스턴스는 String key 와 패키지 이름이 같으며,
// --> 프록시 인스턴스의 경우 String key 와 패키지 이름이 같지 않다.
Map<String, Object> singletonContainer;
// 생성자로부터 건네받은 "모든" 클래스 메타데이터를 의미한다.
List<Class<?>> clazzList;
// 이 프로세스에서, 순환 참조를 허용 할 것인지, 엄격하게 실행 할 것인지 설정한다.
boolean isAllowCircular;
// 필요한 데이터와 자료구조 세팅.
public ResolveDependency(List<Class<?>> clazzList, boolean allowCircularDependency) {
this.clazzList = clazzList;
this.isAllowCircular = allowCircularDependency;
this.dependencyMap = new HashMap<>();
this.completeQueue = new LinkedList<>();
this.dependencyTracker = new HashMap<>();
this.lazyMap = new HashMap<>();
this.singletonContainer = new HashMap<>();
}
// ...
}
ResolveDependency 는 클래스의 메타데이터를 읽고,
내부에 적용된 애너테이션에 대한 적용, 및 "의존성 해결" 을 목표로 한다.
따라서 생성자는 인자로 Class<?>[] clazzArrs 배열 하나를 받을 것이다.
즉, ResearchPackage 에서 탐색된 클래스 메타데이터를 ResolveDependency 로 옮긴다.
또한 isAllowCircular 불린 값으로, 순환 참조를 허용 할 것인지, 아닌지 정한다.
이는 @MyLazy 를 선언하지도 않은 "완전한 순환참조" 를 의미한다.
수많은 자료구조들이 위에 떠 있는데, 이는 프로그램을 구축하면서
필수적인 "의존성 해결" 뿐만 아니라, 몇 가지 유틸적인 기능을 넣기 위함이다.
솔직히, "순환참조도 상관없는 의존성 컨테이너 제작" 을 선택한다면,
5 개의 자료구조는 사라져도 된다.
그러나 최소한 사용자에게 있어 올바른 아키텍쳐의 구성을 유도하도록 만드는 것이 올바르다고 판단했다.
Proxy API 와 적용 메서드들
Java Proxy API 는 Java 라는 언어 자체의 엄격함과 그로부터 나온 한계로 인해
공식 문서에서 배우면서 의아한 경우가 많았다.
그러나 직접 여러 상황에서의 Proxy API 를 적용하다 보니, 어떤 흐름으로 이해해야 하는지
알게 되었다.
컴퓨터 로직에서의 Proxy 란, 원본을 거치기 전 특정한 Logic 을 수행해 주는 존재라고 할 수 있다.
프로그래밍에서의 Proxy 란, 원본 객체를 거치기 전 그리고 후, 특정한 Logic 을 수행하는 존재이다.
어떤 의미에서 Proxy 란, Interceptor(가로채기) 라는 개념과 매우 비슷하기도 하다.
Spring 에서 AOP, Proxy 를 적용하는 과정은 다음과 같다.
@Aspect 애너테이션과 스키마(Schema) 를 통한 일괄 적용
사실, Spring 을 제대로 배우지 않고 Container 를 제작해서 잘 모른다..
이를 알기 위해 Spring Documentation 을 읽어봤는데,
Spring 은 "책임의 분리" 를 완벽하게 해내기 위해 또 다른 시스템을 도입했다.
내가 만들게 되는 Logic 과
즉, 옛날 시스템, EJB(Enterprise Java Bean) 의 Proxy 적용 과정은 매우 흡사하다.
"그러나"
Spring 은 EJB 의 프록시 적용 과정에서,
개발자들이 "메서드 실행/종료 체크" 와 같은 간단한 AOP 적용조차,
클래스와 인터페이스, 그리고 위에서 질리도록 말한 InvocationHandler 의 구현이
뒤따를 수 밖에 없다는 것을 인지했을 것이다.
"배우면 되지 않나?" 라고 생각하면 되지만, Spring 의 개발자들은 더 넓은 시야를 가졌던 것 같다.
내가 설명하는 것은 완벽히 동일하지 않지만,
Spring 은,
- AOP 로직을 만든다.
- AOP 클래스 Bean 내부에 "어떤 패키지를 기준으로 전부 적용할 것인지" 정한다.
- 이 때, AOP 로직(Proxy 로직) 이 적용되는 컴포넌트는 "이를 알지 못한다."
위의 과정은 "책임의 분리" 자체를 성실히 수행한 결과이다.
그러나 일반적인 Proxy 적용과 Spring 의 AOP 적용은 둘 다 치명적인 문제를 가진다.
바로, "final" 은 어떻게 할 수가 없다는 것이다.
Proxy 과정에서 Spring 조차 어찌 할 수 없는 final
내가 만든 프로그램과 Spring 조차도, Proxy 에 final 값을 수정할 수가 없다.
Java 라는 언어와, 비슷한 저 수준의 언어를 이해해야 하는데,
원래 final 이라는 예약어는 "생성자에서조차 정할 수 없는 자료구조" 이다.
그냥 코드에 값 자체를 "지정" 해야 한다.
그런데, Java 는 final 을 도입 할 때,
JVM 런타임 시 이 값을 변경 할 수 있는 단 하나의 단계만을 남겨두었다.
그게 바로 생성자, (Constructor) 부분이다.
이 부분을 제외한 단계에서는 변경 할 방법이 없다.
Spring 은 Proxy(AOP) 를 제작 할 때,
기본 컴포넌트의 "자식"(extends) 화 컴포넌트를 만들어서 적용한다.
그러나, final 은 이를 접근할 수 없다는 문제가 존재한다.
클래스, 메서드에 final 을 붙이면, 상속이 불가능해지기 때문이다.
Proxy API 사용법
단순 Java 언어를 사용해 본 사람들 중에서도, Proxy 를 사용 해 본 사람은 적을 것이다.
알고리즘 문제를 푸는 데에서 굳이 프록시를 적용 할 일은 없기도 하고,
딱히 간단한 CRUD API 를 만드는 과정에서도 Proxy API 를 건들 일이 없다.
차라리 AI 를 시켜서 Spring 에서 제작한 Custom Proxy 로직을 채택 할 것이다.
그러나, 나는 순수 Java 와 내장 API 만을 사용하여 컨테이너를 만들겠다고 선언했다.
앞으로 선언될 컴포넌트에 적용될 Proxy 를 "이해하기 위해서"는,
Java 의 내장 Proxy API 를 이해해야 한다.
간단히 내가 만든 Java 예제와 함께 알아보자.
- 먼저 간단하게 적용할 클래스와, 인터페이스를 만들자.
interface Component {
public void testMethod();
}
// 무조건 Component 를 구현했다고 코드로 작성해야 한다.
public class ComponentImpl implements Component{
private Integer number;
public ComponentImpl(int number) {
this.number = number;
}
public void testMethod() {
System.out.println("컴포넌트의 숫자 변수는 : " + number + " 입니다.");
}
}
자, 위와 같은 interface, class 를 선언하자.
그리고, PROXY 역할을 수행할 클래스 (InvocationHandler 구현체) 를 만든다.
public class TestProxy implements InvocationHandler {
// Proxy 에 감싸질 "원본 인스턴스 객체" 를 넣게 된다.
private final Object target;
// 인자로 원본 인스턴스를 받는다. --> 필수적인 템플릿
public TestProxy(Object target) {
this.target = target;
}
// InvocationHandler 의 메서드 구현체.
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("Proxy 내부 --> 원본 메서드 실행 이전");
Object result = method.invoke(target, args);
System.out.println("Proxy 내부 --> 원본 메서드 실행 이후");
return result;
}
}
위의 InvocationHandler 구현체인 TestProxy 는,
메서드인 invoke 에서, 원본 객체 메서드 실행 직/후 Logic 을 만들 수 있다.
여기서 메서드 실행 시간을 측정 할 수도 있다.
그리고 위의 선언들을 이용하여 진정한 Proxy 객체를 만들어 보자.
public class Main {
public static void main(String[] args) {
// 원본 객체를 생성한다.
ComponentImpl originInstance = new ComponentImpl(3);
// 원본 인스턴스의 객체 데이터를 뽑는다.
Class<?> clazz = originInstance.getClass();
// = ComponentImpl.class 와 동일.
// InvocationHandler 클래스의 인스턴스가 필요하다.
InvocationHandler handler = new TestProxy(originInstance);
// 프록시 API 를 이용하여 프록시 객체를 만들어 낸다.
Component proxyInstance = (Component) Proxy.newProxyInstance(
// 이 클래스 메타데이터를 가져온 "클래스 로더" 객체를 가져온다.
clazz.getClassLoader(),
// 객체에서 proxy 처리될 메서드를 지정하는 행위와 동일하다.
// 인스턴스에서 추출한 인터페이스 리스트를 그대로 넣는 것 또한 가능하다.
new Class<?>[] {Component.class},
// 어떤 proxy 기능을 넣을건지 지정한 InvocationHandler 구현체를 넣는다.
handler
);
// proxy 객체에서 "지정된 proxy 메서드" 를 실행 할 수 있다.
proxyInstance.testMethod();
}
}
위의 과정을 통해 Java 언어에서의 일반적인 Proxy Instance 생성 과정을 알아보았다.
그러나, 메타 프로그래밍에서의 Proxy Instance 생성 코드는 굉장히 불친절할 것이다.
위의 예시에서는 직접적으로 "Type" 이 지정되었지만,
아쉽게도 프로그램에서는
Object(인스턴스), Class<?> (클래스 메타데이터, 인터페이스, 애너테이션 등등)
이렇게 표현된다.
따라서 프로그램을 구축하면서, 해당 Object, Class<?> 타입의 변수들이
어떠한 값을 담고 있는지 Naming Convention 을 지정하는 것이 좋다.
Proxy API 적용 메서드들
나는 Proxy 관련된 Custom Annotation 을 2 개 만들었다. (위에서 작성한 대로.)
MyProxyMyProxies
Annotation 의 코드 지원으로 @Repeatable 을 넣고,
MyProxy 를 여러 번 작성할 수 있었는데,
단일 프록시와 다중 프록시의 적용은 다른 역할을 수행한다고 생각하고 이를 나누었다.
MyProxy 와 MyProxies 는 동일하게 컴포넌트에 적용될 interface 를 하나 가지지만,
MyProxy 는 InvocationHandler 클래스 하나, Class<?>
MyProxies 는 InvocationHandler 배열을 가진다. Class<?>[]
나는 의존성 해결 Logic 을 먼저 작성하고 난 뒤, Proxy 메서드를 작성했는데,
"너무 많은 분기점이 Clean Code" 를 방해하고 있었다."
각각의 애너테이션들은 Main Logic 에서 제 각각의 역할을 수행한다.
문제는, MyProxy, MyProxies 를 유사하게 다루도록 만들어야 했다.
그리고 추후 Spring 의 @Aspect 와 비슷한 로직을 만들지도 몰라서,
애너테이션은 각기 다르나, 로직은 하나의 줄기로 흐르도록 만들어야 했다.
flowchart TB
MyProxy("MyProxy")
MyProxies("MyProxies")
proxyProcess("proxyProcess - 메서드")
insertProxies("insertProxies - 메서드")
insertProxy("insertProxy - 메서드")
MyProxy --> proxyProcess
MyProxies --> proxyProcess
proxyProcess --> insertProxies
insertProxies --MyProxy 일 경우, 한 번 실행--> insertProxy
insertProxies --MyProxies 일 경우, 여러 번 실행---> insertProxy
/**
* 프록시 적용 로직에서 사용되는 Custom Type
*/
package com.damsoon.util.type;
public class ProxyInfo {
public Class<?> handler;
public Class<?> targetInterface;
public ProxyInfo(Class<?> handler, Class<?> targetInterface) {
this.handler = handler;
this.targetInterface = targetInterface;
}
}
// --- ResolveDependency 클래스 메서드 중 일부
/**
* 컴포넌트의 MyProxy, MyProxies 둘 중 하나에 표기되어 있는 정보를 토대로 "새로운 프록시" 객체를 만들어 낸다.
*
* @param proxy 컴포넌트에 단일 프록시 기능만 들어가 있을 경우, null 이 아니다.
* @param proxies 컴포넌트에 다중 프록시 기능이 들어가 있을 경우, null 이 아니다.
* 이 둘 중 하나는 null 이다.
* @param instance 프록시가 적용된 타겟 인스턴스이다.
* @param clazz 프록시가 적용되는 클래스 메타데이터 정보이다. 프록시 적용을 위해 모든 인터페이스 정보를 읽기 위함이다.
* @return 프록시가 적용된 객체(<code>Object</code>) 를 반환한다. 그러나, 기존 instance 와 묶여진 target Interface 외 어떠한 연결점도 없다.
* 따라서 <code>completeQueue</code> 에 들어가기 전에만 사용된다.
*/
public Object proxyProcess(MyProxy proxy, MyProxies proxies, Object instance, Class<?> clazz) {
// 단일이던, 다중이던 하나의 자료구조 "List" 로 만든다.
// 이는 Iterator 로 모두 처리하기 위함이다.
List<ProxyInfo> proxyList = new ArrayList<>();
// 단일 프록시와 다중 프록시의 로직을 하나로 묶기 위해 자료구조 ProxyInfo 적용
// ProxyInfo 는 핸들러와 인터페이스 정보를 각각 하나씩 가짐.
if(proxy != null) {
proxyList.add(new ProxyInfo(proxy.proxy(), proxy.targetInterface()));
} else { // proxies 가 있는 상황
Class<?>[] proxyArr = proxies.proxies();
Class<?> targetInterface = proxies.targetInterface();
for(int i = 0; i < proxyArr.length; i++) {
proxyList.add(new ProxyInfo(proxyArr[i], targetInterface));
}
}
// proxy 적용
Iterator<ProxyInfo> proxyIterator = proxyList.iterator();
instance = this.insertProxies(proxyIterator, instance, clazz);
return instance;
}
// 프록시 객체 생성에 필요한 3 가지가 인자로 들어가는데,
// 1. InvacationHandler 를 구현한 객체 메타데이터
// 2. 프록시가 적용될 객체의 '인스턴스'
// 3. 프록시가 적용될 객체 클래스의 모든 정보. --> 구현된 인터페이스를 모두 추출하기 위함
public Object insertProxy(Class<?> handlerInfo, Class<?> targetInterface, Object instance, Class<?> clazzInfo) {
Constructor handlerConstructor = null;
InvocationHandler handlerInstance = null;
// handlerInfo 에서 생성자를 꺼내는 것은 "무조건" 성공해야 하는 작업이기 때문에,
// 실패 시 null 이 아니라 Exception 이 된다.
// 에러를 담당하기 위해 try, catch 문을 사용한다.
try {
handlerConstructor = handlerInfo.getConstructor(Object.class);
handlerInstance = (InvocationHandler) handlerConstructor.newInstance(instance);
} catch (ReflectiveOperationException e) {
String ErrorText = ColorText.red("[Error in Creating Proxy Instance] : \n")
+ ColorText.red("--> " + handlerInfo.getName() + " - 핸들러 에러 발생");
throw new RuntimeException(e);
}
// 새로운 프록시 객체를 만들고, 원본 객체와 다른 주소를 반환한다.
// 새로운 주소는 프록시 객체의 주소를 의미한다.
Object proxyObject = Proxy.newProxyInstance(
clazzInfo.getClassLoader(),
new Class<?>[] {targetInterface},
handlerInstance
);
return proxyObject;
}
public Object insertProxies(Iterator<ProxyInfo> iterator, Object instance, Class<?> clazz) {
// MyProxy 라면 1 번만 순회하며, MyProxies 는 여러번 순회한다.
while(iterator.hasNext()) {
ProxyInfo tmpProxy = iterator.next();
try {
// 프록시가 적용 될 때 마다, 반환된 instance 객체의 주소는 다르다.
instance = insertProxy(tmpProxy.handler, tmpProxy.targetInterface, instance, clazz);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
return instance;
}
생각보다 프록시 적용을 위해 복잡한 로직을 작성했다고 생각했을 수도 있고,
더 나은 방식이 존재할 것이다.
이 코드 위에 보여주었던 Proxy Instance 의 생성과는 달리,
내가 작성한 Proxy 적용의 코드가 더 복잡한 이유는,
"우리는 개발자가 작성한 프록시의 정보를 모른다." 라는 특수성 때문이다.
프레임워크의 특성은 사용자가 규칙 내에서만 충족한다면, 어떻게 작성되든 상관없이 동작해야 한다.
즉, 프로그램이 구동되고 메타데이터가 증축되는 그 순간부터, 모든 정보는
"어떤 타입을 가졌는지 알지 못한다."
따라서 어떠한 타입이던지 Meta Data 에 속한다면 Class<?> 를,
어떠한 타입이던지 Instance 에 속한다면, Object 타입으로 처리하게 된다.
"의존성 해소를 위해서는 Type 의 구분이 무조건 필요하지 않나?"
이 고민은 의존성 해결 로직을 작성하면서 끊임없이 생각했던 주제였다.
나는 의존성 대기 맵, Map<String, List<WaitingField>> dependencyMap 을 선언하여
기다리고 있는 객체에 대한 정보를 "문자열" 로 지정했다.
이 덕분에, 아무리 프록시가 적용되어 객체 주소와 실제 타입(EX - Proxy7$) 이 다르다고 한들,
문자열로 해당 의존성을 지정해 버리니, com.damsoon.component.Component 와 같이 등록한다면,
내부의 실제 객체는 Proxy7$ 이더라도, 해당 문자열에 해당하는 인스턴스가 생성되기를 기다리는 리스트에
Proxy7$ 을 넣어서 의존성을 해결 해 줄 수 있는 것이다.
위의 과정은 의존성 해결 로직과 함께 설명해야 하는데, 이해가 잘 되지 않는다면 그게 정상일 것이라고 생각한다.
의존성 해결 로직과 책임의 분리
나는 "순환참조" 구조도 받아들이는 프로그램을 제작한다고 말했었다.
Spring 은 이미 @Lazy 애너테이션 표식을 통해,
클래스 내부의 필요 의존성을 나중으로 미룰 수 있는 것이다.
그러나 나는 여기서 한 발짝 더 나아갔다.
@Lazy 와 같은 표식(애너테이션) 도 추가하되(@MyLazy)
이 표식조차도 갖춰지지 않은 완벽한 순환참조 구조조차도 받아들이자.
스프링은 객체를 인식하고, 필요 의존성을 완성하기 위해, 필요 의존성의 필요 의존성을 마무리해야
객체 내부의 필요 의존성을 완성하여, 객체를 완성할 수 있는 것이다. (DFS) 흐름과 유사하다.
그런데, 나의 방식은 완전히 달랐다.
객체를 인식하는 순간, 해당 객체는 "의존성이 완성 된 것 처럼" 만들어 버린다.
비록 완전한 인스턴스 객체가 아니라, 가짜 의존성이지만, 어쨌든 Instance 가 존재하는 것이다.
이 과정을 "모든 컴포넌트" 에서 먼저 실행하고, 한 번만 순회하여 간단히 의존성을 완성 할 수도 있었다.
그러나 이 방식은 좋은 코드, 즉, 올바른 템플릿으로 유도하지 않고 스파게티 코드를 유발하는 프로그램이 될 것이라고 생각했다.
이에 나는 의존성 해결 로직을 "3 단계" 로 나누었다.
- 순환 참조나 여타 문제가 없는 구조의 컴포넌트들을 완성한다.
- Annotation 으로 인해 의존성 완성을 미룬
@Lazy의존성을 넣어주고 완성한다. - 완벽한 순환 참조를 이루는 비정상적인 컴포넌트 의존성을 해소 해 주되, "경고한다."
위의 과정을 수행하기 위해, 여러 자료구조를 생성했다.
HashMap<String, List<WaitingField>> dependencyMapHashMap<String, List<WaitingField>> lazyMapMap<Object, AtomicInteger> dependencyTrackerQueue<CompleteQueue> completeQueueMap<String, Object> singletonContainer해당 패키지를 기다리는 (인스턴스-
Field) 쌍 배열이 대기하는 곳.
String에 해당하는 컴포넌트가 "완성" 되면,WaitingField들을 순회하며 의존성을 주입한다.
MyLazy가 의존성에 선언되었을 때, 해당 패키지를 기다리는 (인스턴스-`Field) 쌍 배열이 대기하는 곳
@MyLazy가 붙은 의존성일 경우 이 Map 에 등록되며, 두 번째 의존성 해결 로직에서 해결한다.
- 미완성 인스턴스가 생성되지마자, 해당 객체는 몇 개의 의존성을 가지는지 기록하는 곳
- 어떤 미완성 객체가 의존성을 하나 해결하면, 해당 객체의 수(
AtomicInteger) 가 1 줄어든다. 0이 되었을 경우, 이 자료구조에서delete하며completeQueue에 들어간다.
- 미완성 인스턴스가 "완성" 되었을 때, 들어가는 곳.
- "의존성 완성" 된 컴포넌트는 자신을 원하는 장소에 주소를 주입하며, 프록시 처리를 위해 대기한다.(큐에서)
- 완성 인스턴스가 자신을 필요하는 장소에 주입하고, 프록시 적용 시 적용 이후 들어가는 완전체 컨테이너.
- 더 이상 처리 할 것이 없는 완벽한 컴포넌트들이 들어가는 곳이다.
- 스스로 수정 할 것은 없지만, 필요시 "참조" 될 수 있다.
의존성 해결 예시를 위한 컴포넌트 Example
flowchart TB
subgraph Component1 ["Component1"]
Depen-Com1-1("의존 - Component2")
end
subgraph Component2 ["Component2"]
Depen-Com2-1("의존 - {Lazy} Component1")
Depen-Com2-2("의존 - Component3")
end
subgraph Component3 ["Component3"]
Depen-Com3-1("의존성 없음")
end
subgraph Component4 ["Component4"]
Depen-Com4-1("의존 - Component3")
end
subgraph Component5 ["Component5"]
Depen-Com5-1("의존 - Component6")
end
subgraph Component6 ["Component6"]
Depen-Com6-1("의존 - Component5")
end
Component1 ~~~ Component2
Component3 ~~~ Component4
Component5 ~~~ Component6
앞으로 보여줄 3 단계의 의존성 해결 프로세스를 설명하기 위해 위와 같은 그래프를 제작했다.
간단한 설명을 위해 Proxy 표식은 넣지 않았으며,
이를 통해
- 일반적인 컴포넌트 의존성 해결
- Lazy 의존성 해결
- 완벽한 순환참조 고리에 갇힌 의존성 해결
프로세스를 설명 할 것이다.
위의 컴포넌트들이 존재한다면, 각각의 자료구조에는 이러한 데이터가 저장된다.
Queue<CompleteObject> completeQueue- 완성된
Component3인스턴스
- 완성된
Map<String, List<WaitingField>> dependencyMap"com.damsoon.component.Component2
[ {컴포넌트1-Field, 컴포넌트1-instance} ]
"com.damsoon.component.Component3"
[ {컴포넌트2-Field, 컴포넌트2-instance}, {컴포넌트4-Field, 컴포넌트4-instance} ]
"com.damsoon.component.Component5"
[ {컴포넌트6-Field, 컴포넌트6-instance} ]
"com.damsoon.component.Component6"
[ {컴포넌트5-Field, 컴포넌트5-instance} ]
Map<String, List<WaitingField>> lazyMap"com.damsoon.component.Component1
[ {컴포넌트2-Field, 컴포넌트2-instance} ]
Map<Object, AtomicInteger> dependencyTrackerComponent1 인스턴스--1Component2 인스턴스--1Component4 인스턴스--1Component5 인스턴스--1Component6 인스턴스--1
Map<String, Object> singletonContainer- 비어 있음.
-dependencyTracker 를 보면서 들 수 있는 의문.-
Component2 를 보면, 가지고 있는 총 의존성은 2 개이다.
그런데, dependencyTracker 에서 참조하면, 의존성은 1 개이다.
이는 "2단계 의존성 해소 로직" 에서 이루어질 행동 때문인데,
일반 의존성과는 달리 해석하는 것이다.
의존성을 읽으며, @MyLazy 표기된 의존성은 "의존성 수" 에 더해지지 않는다.
의존성 해결 1 단계 - 특수성을 지니지 않은 컴포넌트 의존성 해결
위에서 의존성 해결을 3 단계로 나누겠다고 선언했고, 이는 그 중 첫 번째 로직이다.
일반적인 의존성을 해결하는 단계이다.
우리가 눈여겨 봐야 할 ResolveDependency 클래스의 변수 자료구조가 있다.
바로,
Map<String, List<WaitingField>> dependencyMapQueue<CompleteObject> completeQueueMap<Object, AtomicInteger> dependencyTrakcerMap<String, Object> singletonContainer
이 3 개 이다.
이 단계가 시작하기 위해서는,
어떠한 외부적인 요인으로 인해 "모든 의존성이 해소" 된 컴포넌트나,
애초부터 "어떠한 의존성도 없는" 컴포넌트가 필요하다.
처음 모든 컴포넌트 메타데이터를 읽을 때, 위에 작성해 놓은 3 가지 자료구조에 모든 정보가 들어간다.
Queue 자료구조에는 의존성이 없거나 모두 해소되어 "다른 컴포넌트의 의존성을 해소"하기위해 대기하는 장소이다.
dependencyMap 은 컴포넌트의 의존성이 모두 해소되지 못하여 instance-Field 쌍 배열로 대기한다.
dependencyTracker 은 의존성이 모두 해소되지 못한 인스턴스가 현재 "몇 개의 의존성" 을 아직 필요로 하는지 기록한다.
"위의 자료구조들로 의존성 해소를 시작 해 보자."
먼저, Queue 에 적어도 하나의 완성 인스턴스가 존재한다고 가정한다.
---
title : Logic 1 -
---
flowchart TB
subgraph Queue["Queue"]
Queue-Purpose("의존성이 모두 해소된 인스턴스들이 들어간다.")
end
subgraph DependencyMap["dependencyMap"]
DependencyMap-Purpose("어떤 의존성을 기다리는지, <br/> (인스턴스-필드) 쌍 구조로 기다린다.")
end
subgraph DependencyTracker["dependencyTrakcer"]
DependencyTracker-Purpose("의존성 미해소 인스턴스가 현재 몇 개의 의존성을 필요로 하는지 표기한다.")
end
subgraph SingletonContainer["singletonContainer"]
SingletonContainer-Purpose("더 이상 수정될 필요가 없는 완성 인스턴스가 정착하는 마지막 장소")
end
DependencyTracker ~~~ SingletonContainer
subgraph Logic1["Logic 1 - 시작"]
direction TB
Logic1-1("Queue 에서 poll 한다. <br/> (완성 인스턴스.)")
Logic1-2("완성 인스턴스의 이름, String 을 추출한다.")
Logic1-3("String 으로 dependencyMap.get 한다.")
Logic1-4("추출된 WaitingField 리스트를 순회한다.")
Logic1-1 -.- Logic1-2
Logic1-2 -.- Logic1-3
Logic1-3 -.- Logic1-4
end
Queue --완성 인스턴스 하나를 건네준다--> Logic1
DependencyMap --검색된 이름으로 대기 리스트를 건네준다--> Logic1-3
flowchart TB
subgraph Queue["Queue"]
Queue-Purpose("의존성이 모두 해소된 인스턴스들이 들어간다.")
end
subgraph DependencyMap["dependencyMap"]
DependencyMap-Purpose("어떤 의존성을 기다리는지, <br/> (인스턴스-필드) 쌍 구조로 기다린다.")
end
subgraph DependencyTracker["dependencyTrakcer"]
DependencyTracker-Purpose("의존성 미해소 인스턴스가 현재 몇 개의 의존성을 필요로 하는지 표기한다.")
end
subgraph SingletonContainer["singletonContainer"]
SingletonContainer-Purpose("더 이상 수정될 필요가 없는 완성 인스턴스가 정착하는 마지막 장소")
end
DependencyMap ~~~ SingletonContainer
subgraph Logic2["Logic 2 - 순회 내부"]
direction TB
Logic2-1("인스턴스-Field 에 완성 인스턴스를 Field 에 넣어준다.")
Logic2-2("field.set(인스턴스, 완성 인스턴스) 가 된다.")
Logic2-3("만약 순회 중 인스턴스 의존성이 해소되었다면, Queue 에 넣는다.")
Logic2-4("해소된 인스턴스는 dependencyTracker 에서 제거한다.")
Logic2-1 -.- Logic2-2
Logic2-2 -.- Logic2-3
Logic2-3 -.- Logic2-4
end
Logic2-3 <--현재 필요 의존성 수를 검색--> DependencyTracker
Logic2-3 --의존성이 모두 해소된 완성 인스턴스 추가--> Queue
Logic2-4 --해소 인스턴스는 DependencyTracker 에서 제거한다--> DependencyTracker
flowchart TB
subgraph Queue["Queue"]
Queue-Purpose("의존성이 모두 해소된 인스턴스들이 들어간다.")
end
subgraph DependencyMap["dependencyMap"]
DependencyMap-Purpose("어떤 의존성을 기다리는지, <br/> (인스턴스-필드) 쌍 구조로 기다린다.")
end
subgraph DependencyTracker["dependencyTrakcer"]
DependencyTracker-Purpose("의존성 미해소 인스턴스가 현재 몇 개의 의존성을 필요로 하는지 표기한다.")
end
subgraph SingletonContainer["singletonContainer"]
SingletonContainer-Purpose("더 이상 수정될 필요가 없는 완성 인스턴스가 정착하는 마지막 장소")
end
Queue ~~~ DependencyTracker
subgraph Logic3["Logic 3 - 마무리"]
direction TB
Logic3-1("Queue 에서 poll 했던 완성 인스턴스는 Container 로 들어간다.");
Logic3-2("완성 인스턴스의 이름으로 등록된 의존성 대기줄을 삭제한다.");
Logic3-3("Queue 가 빌 때 까지, Logic1 으로 되돌아간다.")
Logic3-1 -.- Logic3-2
Logic3-2 -.- Logic3-3
end
Logic3-1 --수정이 필요없는 완성 인스턴스--> SingletonContainer
Logic3-2 --자신을 대기하는 의존성은 없다--> DependencyMap
Logic1, 2, 3 를 전부 하나의 그래프에 넣으려고 했는데,
너무 난잡하여 이를 나누어 표기했다.
Logic 1 은 "완성 인스턴스" 를 기준으로 의존성 해소를 위한 준비 작업이며,
Logic 2 는 추출된 List<WaitingField> 를 순회할 때 내부의 로직이다.
Logic 3 는 순회가 끝나고 "완성 인스턴스 후처리" 를 의미한다.
"그러면 추후 의존성 해결 2 단계와 3 단계는 더 어려운가?"
"전혀 그렇지 않다."
2 단계와 3 단계는 전부 1 단계에서 만들어진 로직을 재활용하여 처리한다.
표기된 순환 참조 의존성 해소와,
완전한 순환 참조 의존성 해소는 엄연히 그 의미가 다르다.
의존성 해결 1단계를 이용한 컴포넌트 예시
간단히 표시 할 건데, 이를 이해하기 위한 설명이 필요하다.
1 단계 의존성 해결은 "일반 의존성 해결" 을 의미한다.
시작하기 위해서는 메타데이터를 읽으며 "의존성 해소가 필요 없는" 컴포넌트가 최소 1 개는 필요하다.
내가 만든 컴포넌트 예시에, Component3 는 의존성이 존재하지 않는다.
그렇다면, Component3 는 이미 Queue 에 들어가 있다.
1 단계의 시작은 Queue.poll 로 시작하기 때문에, Component3 를 기준으로,
Component3 를 의존성으로 삼은 컴포넌트들(List<WaitingField>) 을 순회하게 된다.
Queue(completeQueue)
| add | poll | |
|---|---|---|
| Component3 |
1. Component3 로 의존성 해소
미리 Queue 에 들어가 있던 Component3 를 기준으로, 의존성 해소를 시작하게 된다.
Component3 를 원하는 컴포넌트는 Component4, Component2 이다.
Component4, Component2 는 Component3 의존성을 해결하면,
Queue 에 들어간다.
이 때의 결과는,
Component3인스턴스는singletonContainer에 들어간다.Component2,Component4인스턴스는Queue에 들어간다.
- 이 2 개의 인스턴스는
dependencyTracker에서 제거한다.
Component3는dependencyMap에서 제거하고,singletonContainer로 들어간다.
Queue(completeQueue)
| add | poll | ||
|---|---|---|---|
| Component4 | Component2 |
2. Component2 로 의존성 해소
컴포넌트3 를 해소하고, 컴포넌트 2, 4 가 현재 Queue 에 들어가 있다.
그 다음으로 Component2 이다.
Component2 를 기다리는 인스턴스는 Component1 하나뿐이다.
Component2인스턴스는Component1필드에 자신의 주소를 "주입" 하고singletonContainer에 들어간다.Component1인스턴스는Queue에 들어간다.
Component1은dependencyTracker에서 제거한다.
Component2는dependencyMap에서 제거하고,singletonContainer로 들어간다.
Queue(completeQueue)
| add | poll | ||
|---|---|---|---|
| Component1 | Component4 |
3. Component4 로 의존성 해소
Component4 를 기다리는 인스턴스는 존재하지 않는다.
Component4인스턴스는 자신을 기다리는 의존성이 등록되어 있지 않다.
- 즉,
dependencyMap에"com.damsoon.component.Component4"key 가 없다는 의미.
Component4를dependencyTrakcer에서 제거한다.Component4를singletonContainer에 등록한다.
Queue(completeQueue)
| add | poll | |
|---|---|---|
| Component1 |
Component1 을 기다리는 인스턴스는 Component2 이지만, @MyLazy 로 등록되어 있다.
(이는 "일반 의존성" 이 아니므로, 2 단계 의존성 해결 프로세스에서 해결한다.)
따라서, Component1 을 기다리는 인스턴스는 존재하지 않는다. (의존성 해결 1단계에서 없다.)
Component1인스턴스는dependencyMap에 자신을 기다리는 의존성이 없다.Component1을dependencyTracker에서 제거한다.Component1을singletonContainer에 등록한다.
Queue(completeQueue)
| add | poll | |
|---|---|---|
| 비어있음 |
Queue 가 비어지면서, 의존성 해결 1 단계는 마무리가 되었다.
그 다음 의존성 해결 2 단계를 시작하게 된다.
의존성 해결 2 단계 - @MyLazy 표기된 의존성 해결
애초에 완벽한 순환참조, 개발자조차 인지하지 못한 미해결 의존성 고리를 해결 할 수 있는 나의 프로그램에서
@MyLazy 표기가 왜 필요한지 이해가 안 될 수 있다.
그렇다면, 나는 2 단계, @MyLazy 표기에 대한 의존성 해결 단계를 왜 넣었을까?
이는 나의 프로그램이 "Framework" 라는 정체성을 가진 순간부터 매우 중요한 의미를 가진다.
프레임워크라는 것은, 사용자가 편리하게 프로그램을 만드는 것에 의의가 있다.
그러나 모든 프레임워크는, "사용자가 올바른 아키텍쳐를 만들도록 유도" 하는 데 책임이 있다.
그토록 편리한 프레임워크의 대명사인 Spring 조차도, @Lazy 의 사용은 자제하도록 유도한다.
그 이유는, "기술의 부채", Technical Dept 에 가깝다.
"컴포넌트" 라 함은, 결국 재활용성에 가깝다.
하나의 컴포넌트는 다른 여러 컴포넌트에서 사용을 원할 수도,
혹은 거꾸로 하나의 컴포넌트가 다른 여러 컴포넌트를 원할 수 있다.
만약에 SingletonContainer 라는 개념이 존재하지 않는다면,
하나의 컴포넌트는 원하는 여러 클래스를 "제작 및 보관" 하게 되며,
하나의 클래스가 몇십개의 컴포넌트에서 "제작" 되는 것이 된다.
이는 심각한 메모리 낭비에 가깝다.
이렇기에 "유일성"(Unique) 을 지닌 "컴포넌트" 로직 개념을 만들어 사용하는 것이다.
이러한 컴포넌트의 역할을 통해 메모리의 누수를 방지하지만,
사용자가 스파게티 코드를 제작하게 된다면, 프레임워크가 지닌 특유의 장점은 사라지게 된다.
이러한 의미에서, 컴포넌트 클래스 제작 시, @Lazy, 나의 프로그램에서 @MyLazy 를 사용하는 것은,
제작한 컴포넌트의 의존성이 "순환 참조"(Circular Dependency) 상황에 이르렀음을 인지한다는 것이다.
내가 하고 싶은 말은,
프레임워크가 지닌 역할 중, 사용자가 올바른 아키텍쳐를 만들도록 유도한다 라는 점을 고려하여
2 단계, @MyLazy 선언된 의존성을 따로 처리하는 2 단계 로직을 만들었다는 것이다.
2 단계 의존성 해결 예시를 위한 컴포넌트 "현황"
flowchart TB
subgraph SingletonContainer ["SingletonContainer"]
Component1
Component2
Component3
Component4
end
subgraph Component5 ["Component5"]
Depen-Com5-1("의존 - Component6")
end
subgraph Component6 ["Component6"]
Depen-Com6-1("의존 - Component5")
end
의존성 해결 2 단계 - @MyLazy 표식을 지닌 의존성 해결
현재 ResolveDependency 자료구조가 가진 데이터의 현황을 작성해 보겠다.
Queue<CompleteObject> completeQueue- 비어 있음
Map<String, List<WaitingField>> dependencyMap"com.damsoon.component.Component5"
[ {컴포넌트6-Field, 컴포넌트6-instance} ]
"com.damsoon.component.Component6"
[ {컴포넌트5-Field, 컴포넌트5-instance} ]
Map<String, List<WaitingField>> lazyMap"com.damsoon.component.Component1
[ {컴포넌트2-Field, 컴포넌트2-instance} ]
Map<Object, AtomicInteger> dependencyTrackerComponent5 인스턴스--1Component6 인스턴스--1
Map<String, Object> singletonContainer"com.damsoon.component.Component1""com.damsoon.component.Component2""com.damsoon.component.Component3""com.damsoon.component.Component4"
위의 자료구조 현황을 통해 인지할 수 있는 사실들을 펼쳐보자.
@MyLazy 의존성을 해결하는 2 단계에서는, Queue 자료구조를 사용하지 않는다.
@MyLazy 표식은, 사용자가 스스로 순환 참조임을 인식함과 동시에,
이 표식으로 인해 순환참조의 고리가 해결 될 것이라는 의미를 가지고 있다.
"하지만, 이 표식으로도 완벽한 순환참조를 이룰 수 있다는 상황을 반드시 가정해야 한다."
Map<String, List<WaitingField>> dependencyMap 자료구조에서,
Component5, Component6 서로는 "완벽한 순환참조" 고리를 이루고 있기에 여전히 남아있다.
그리고 Map<String, List<WaitingField>> lazyMap 자료구조는
사용자가 @MyLazy 애너테이션 표기한 의존성 대기 리스트를 등록시키는 장소이다.
의존성 해결 2 단계 프로세스에서 이 맵을 중심으로 행동하게 된다.
이러한 의미인데, Lazy 의존성으로도 해결을 못해? 그렇다면 이 의존성은 3 단계에서 해소한다.
singletonContainer.get("lazyMap key 문자열")
- 의존성 해결을 미뤘으니 이제 컨테이너에 원하는 의존성 인스턴스가 있지?
if(instance == null)==> 아직도 완성되지 않았다면
- 이 분기에 해당한다면, 3 단계에서 해소한다.
- 해당하지 않는다면,
List<WaitingField>의존성 대기열을 해소하고, 제거한다.
라는 의미이다.
dependencyTracker 는 "3 단계 의존성 해소"에서 가장 중요한 역할을 맡게 된다.
의존성 해결 2단계를 이용한 컴포넌트 예시.
flowchart TB
subgraph LazyMap ["LazyMap"]
direction TB
key1("key : com.damsoon.component.Component1")
value1("value : [ { Field(컴포넌트2) - instance(컴포넌트2) } ]")
key1 <--> value1
end
subgraph SingletonContainer ["SingletonContainer"]
Component1
Component2
Component3
Component4
end
subgraph Component5 ["Component5"]
Depen-Com5-1("의존 - Component6")
end
subgraph Component6 ["Component6"]
Depen-Com6-1("의존 - Component5")
end
Component5 ~~~ Component6
의존성 해결 2 단계 에서는 Queue - completeQueue 를 이용하지 않는다.
의존성 해결 1 단계 는 while(!completeQueue.isEmpty()) 라는 조건이 있었다.
즉, 정상적인 의존성 해결이 "완료" 될 때 까지는, 지속적으로 completeQueue.poll 을 하여
의존성을 해소하는 것이다.
그러나, 의존성 해결 2 단계 는 다르다.
Component2 는, @MyLazy Component1 을 선언했다.
이 의미는, Component2 에서 Component1 이라는 의존성 해소를 미룬 것이다.
심지어 dependencyTracker 는 Component2 를 가지고 있지 않다.
왜냐면 "의존성이 완료되었다" 고 인지하기 때문이다.
모든 의존성 1, 2, 3 단계 해소는 "순회 대상" 이 존재한다.
그렇다면 의존성 해결 2 단계에서는 어떤 것이 "순회 대상" 이 될까?
바로, lazyMap.keySet().iterator() 가 된다. (Iterator<WaitingField> iterator)
@MyLazy 선언된 의존성은 이미 "완성되어있다" 고 가정한다.
(물론 완성 안 될 경우, 의존성 3 단계로 던지게 된다.)
그렇다면, lazyMap 에 선언된 모든 Key 값을 가져와서,
singletonContainer 를 조회하며 꺼내면 되기 때문이다.
(말 그대로 완성된 인스턴스라고 판단하기 때문이다.)
Map<String, List<WaitingField>> lazyMap"com.damsoon.component.Component1
List [ {컴포넌트2-Field, 컴포넌트2-instance} ]
우리가 순회하게 될 lazyMap 의 key 는 하나이다. (예시)
따라서, while(iterator.hasNext()) 라는 순회문을 따라,
한 번의 순회를 실행하게 된다.
Map<String, Object> singletonContainer 에서 com.damsoon.component.Component1
을 조회한다.
그렇다면, 컨테이너는 Component1 을 반환한다.
그리고 List [ {컴포넌트2-Field, 컴포넌트2-instance} ] 이 List 에
Component1 을 넣어준다.
그리고 나서 lazyMap 에서 com.damsoon.component.Component1 을 제거한다.
이렇게 "의존성 해결 2 단계" 는 완료된다.
의존성 해결 2 단계에서 문제가 발생 - Lazy 의존성으로도 해결이 안되는 상황
위에서 나는 @MyLazy 는 개발자가 올바른 아키텍트를 만들도록 유도하며,
Lazy 처리함으로서 원하는 의존성이 "완성 인스턴스" 가 될 때 까지 기다리고, 이를 드디어 가져오도록 만들었다.
그러나, 만약에 사용자가 틀렸다면???
즉, @MyLazy 를 사용해도 순환 참조가 해결되지 않는 것이다.
이 때, while(iterator.hasNext()){ ... } 순회문에서 continue 를 사용하여 건너뛴다.
lazyMap 에서 제거하지 않고 건너뛴다.
이 의미는 "의존성 해결 3 단계" 에서 다시 while(iterator.hasNext()) 를 사용하겠다는 것이다.
@MyLazy 표식으로 2단계에서 해소되지 못한 의존성은, 3 단계의 "마지막 로직" 에서 해소하게 된다.
의존성 해소 3단계 진입 시 컴포넌트 예시
flowchart TB
subgraph LazyMap ["LazyMap"]
empty2("비어 있음")
end
subgraph DependencyTracker ["DependencyTracker"]
Com5("컴포 5 인스턴스 - 1")
Com6("컴포 6 인스턴스 - 1")
end
subgraph SingletonContainer ["SingletonContainer"]
Component1
Component2
Component3
Component4
end
subgraph Component5 ["Component5"]
Depen-Com5-1("의존 - Component6")
end
subgraph Component6 ["Component6"]
Depen-Com6-1("의존 - Component5")
end
Component5 ~~~ Component6
내가 의존성 1, 2 단계를 자세히 나열하기 전, 예시로 들은 자료구조의 예시와 지금의 상황이 많이 다르다.
즉, Component1 ~ 4 까지의 모든 컴포넌트는 "완벽한 컴포넌트" 가 된 것이다.
그러나 아직 Component5, Component6 는 "완벽한 순환참조" 로서, 해소되지 못했다.
의존성 해결 1 단계, 2 단계는 각자 기준이 되는 순회문을 가졌다.
- 1 단계 :
while(!completeQueue.isEmpty()) - 2 단계 :
while(lazyIter.hasNext())
그렇다면, 3 단계는 어떤 순회문을 가질까?
바로 Map<Object, AtomicInteger> dependencyTracker 자료구조의 Iterator 를 사용한다.
그런데, 마지막까지 dependencyMap 에 남은 Component5, Component6 를
전부 Queue<CompleteObject> completeQueue 에 넣어놓고,
의존성 1 단계에서 사용했던 while(!completeQueue.isEmpty()) { ... } 를 다시 사용한다.
아니, 남은 의존성을 모두 완성 인스턴스 취급 할 거면, 큐 말고 "순회" 만 하면 되지 않나?
나 또한 이러한 의문 앞에서 고민했는데, 그냥 List<CompleteObject> 지역적 변수를 만들고,
여기에 모두 추가하고 순회하면 되는 일이 아닌가 고민했다.
그러나, dependencyMap 이라는 자료구조가 가진 역할이 매우 약화된다.
결국 모든 의존성을 "억지로" 해소한다는 데에 다를 바는 없지만,
"의존성 해소" 라는 과정은 억지스러워서는 아니된다.
또한 "의존성 해소 1 단계" 의 로직을 재활용하여, 추후 코드를 줄일 생각을 하고 있기 때문이다.
간단하게 보는 의존성 해소 3 단계
Map<Object, AtomicInteger> dependencyTracker를 순회한다.- 순회하며 모두
Queue<CompleteObject> completeQueue에 넣는다. while(!completeQueue.isEmpty())를 통해 인스턴스를poll()한다.poll()된 인스턴스를 통해dependencyMap을 모두 지운다.- 의존성 해소 2 단계(
@MyLazy) 로도 해소되지 못한 의존성을 해소한다. - 이 때
lazyMap.keySet.iterator()를 통해 다시 순회한다. - 마지막에서도 lazy 의존성이 해소될 수 없다면, Critical Error 로 프로그램을 종료한다.
의존성 해소 3단계 - 마지막까지 해소되지 못한 모든 의존성 해소
나는 사용자가 인지하지 못한 "완벽한 순환참조" 형태를 소화 할 수 있도록 만들었지만,
사용자가 이를 허용 할 것인지, 허용하지 않을 것인지 선택 할 수 있도록 만들었다.
이 단계에 들어선다는 것은, 완벽한 순환참조 의존성 관계가 존재한다는 것이다.
따라서, 노란색 경고로 "미해결 된 의존성" 리스트를 출력하고,
사용자가 순환참조를 "허용" 한다면 3 단계 로직을 실행하고,
순환참조를 "금지" 한다면, 3 단계 로직에 들어선 순간 RuntimeError 를 던지고 종료한다.
나는 사용자가 순환참조를 "허용" 했다는 가정 하에 예시를 들 것이다.
현재 남아있는 의존성은 Component5, Component6 이다.
dependencyMap, dependecyTracker 2 개의 자료구조에 남아있다.
첫 번째 : 모든 인스턴스를 Queue 에 넣는다.
의존성 해결 1 단계에서 사용했던 Queue<CompleteQueue> 에 Component5, Component6 에 넣는다.
그렇다면, Queue 는 이러한 형태가 된다.
| add | poll | ||
|---|---|---|---|
| Component6 | Component5 |
두 번째 : Queue 의 모든 인스턴스를 의존성 1 단계 해소 로직처럼 해결한다.
다른 점은, 의존성 1 단계와 달리, Queue 에 새로운 인스턴스가 add 될 일은 없다는 것이다.
단, 인스턴스 하나를 Queue 에서 꺼내서 dependencyMap 과 연계되는 것은 동일하다.
List<WaitingField> 를 뽑아서, 의존성을 해소한다. 그리고, dependencyMap 에서 제거한다.
그리고 해당 인스턴스를 singletonContainer 에 넣는다.
세 번째 : Map<String, List
의존성 해결 2 단계에서, 개발자조차 예상하지 못한 의존성을 마무리하는 단계이다.
즉, @MyLazy 애너테이션을 작성하여 스스로 순환 참조를 다스리고자 했으나,
예상치 못한 구조로 인해 "완벽한 순환참조" 가 된 경우에 해당한다.
즉, lazyMap 의 iterator 를 다시 돌린다.
만약에 이조차도 통하지 않는다면.
Critical Error 로서 에러문을 출력하고, 프로그램을 종료한다.
모든 의존성 해결 단계에서 고려되는 Proxy 적용 타이밍 - 코드를 바로 보여주지 않은 이유.
내가 굳이 코드를 바로 보여주지 않고 "글" 로서 Logic 을 설명한 이유가 여기에 있다.
Proxy API 를 완성된 인스턴스에 적용하는 과정이 난해하여,
코드를 곧장 보게 된다면, 이해되지 않기 때문이다.
그리고 Proxy API 적용으로 인해 코드 양이 늘어났을 뿐만 아니라,
의존성 해결 1, 2, 3 단계에서 공통으로 사용되는 Logic 을 Method 로 분리시키는 과정이 존재하여
코드를 본다 해도 "감으로 이해" 할 뿐이지, "완전한 이해" 는 불가능하기 때문이다.
즉, "순수한 의존성 해결 로직" 에 초점이 맞춰지지 않기 때문이었다.
왜? 프록시 적용 타이밍 때문에 코드가 난해하다는 것인가?
만약에 여기까지 글을 읽었다면, 정말 대단한 분이라는 찬사를 날림과 동시에,
이러한 질문을 드리고 싶다.
"왜 완성된 인스턴스에만 Proxy API 를 적용하는 것인가?"
답은, 원본 객체와, Proxy API 로 생성된 객체는, 완전히 다른 존재이기 때문이다.
원본 객체에 변수 2 개가 있다고 가정하자.(Object origin)
그리고, 이 객체를 참조하여 탄생한 프록시 객체가 있다고 가정하자. (Object proxyObj)
origin 에서 Field (필드 변수) 를 꺼내서 변경할 수 있다.
proxyObj 에서는 origin 이 가지고 있던 Field 가 존재하지 않는다.
따라서, proxyObj 에서 origin 의 Field 를 변경 할 수 없다.
이를 축약하자면, 프록시 객체가 되는 순간, 원본 객체의 Field 를 프록시 객체에서 가져올 수 없다.
나는 클래스 메타데이터를 다루면서, 가짜 의존성들(null) 로만 가득찬 인스턴스에,
곧장 Proxy API 를 적용하려고 했다.
그러나 그것은 불가능했는데, 위에서 언급했듯 Proxy 객체가 되는 그 순간부터, 원본 객체의 Field 를 직접 가져 올 수 없었다.
Field 에 직접 객체 주소를 주입하여 의존성을 해소해야 하는데, Proxy 객체가 되는 그 순간부터,
Field 에 접근할 수가 없으니, 의존성이 해소가 되지 않는 것이다.
이 사실에 나는 "인스턴스가 완전 해 지는 순간" (CompleteObject 되기 직전)
문자열 Name Tag 는 (예시) com.damsoon.component.Component3 이지만,
실제 객체의 문자열 Name Tag (예시) 는 xxx.Proxy6$ 가 되는 것이다.
여기서 Component3 와 Proxy6$ 와의 공통점은, interface 하나밖에 없다.
그러나, 반드시 알아야 할 Proxy 의 구조가 존재한다.
flowchart BT
subgraph Proxy ["Proxy Object - Instance"]
Origin("Origin Object - Instance")
end
Access1("접근법 1 - <br/> Proxy 객체 Field 변경")
Access2("접근법 2 - <br/> Origin 객체 Field 변경")
Access1 --Proxy 는 필드가 없으므로 변경 불가--> Proxy
Access2 --Origin 은 필드가 당연히 있으므로 변경 가능--> Origin
위에서 다룬 Proxy 와 Origin 인스턴스는, 같은 계층의 데이터로서 바라보았다.
Proxy 과정을 겪은 객체는 더 이상 변경 할 수 없다.
그러나, Origin 인스턴스를 저장한다면, Proxy 로 감싸져 있어도 변경할 수 있다.
이러한 사실 속에서 나는 의존성이 완전히 해소된 완벽한 인스턴스가 될 때,
그제서야 Proxy API 를 적용하게 된 것이다.
의존성 해결 1, 2, 3 단계는 모두 "Proxy 적용 Logic" 이 포함되어 있어 클린 코드가 되지 않았다.
잠시만, 나는 바본가? 왜 이걸 생각을 못 했지?
내가 먼저 의존성 해결 로직을 작성하고,
그 다음 Proxy 적용 로직을 작성하고 구성해서 그런 걸 지도 모르는데,
"의존성이 모두 해결 된 다음" 에야 원본 객체를 프록시 객체로 만들고 등록했다.
그런데 나는 바로 위에서 그래프까지 보여가며,
Proxy 객체가 되어도, Origin 객체에 대한 주소만 존재한다면, 수정이 가능하다고 했다.
그렇다면, 일단 먼저 생성된 미완성 인스턴스에 Proxy 를 적용하여 Proxy 인스턴스를 만들고,
원본 객체와 매핑되는 Proxy 인스턴스로 자료구조를 만들면 되는 게 아닌가?
Map<Object, CompleteObject> proxyContainer 를 미리 만들고 사용한다면,
40 줄 정도의 코드를 약 8 줄 정도로 줄일 수 있다는 것이다.
반복되는 코드를 줄이고 싶었는데, 매우 잘 되었다고 생각한다.
의존성 해결 로직 Code
의존성 데이터를 자료구조로 변환하기
각 인스턴스들의 의존성을 해결하기 위해서는,
자료구조들의 초기화가 필요하다.
모든 메타데이터를 읽으며, 적재 적소에 의도했던 대로 적층 한 뒤,
"의존성 해소" 라는 단계에 들어설 수 있다.
public class ResolveDependency {
// 처음에 순회하며 생성자에서 필요로 하는 의존성과 해당 인스턴스를 줄세운다.
private Map<String, List<WaitingField>> dependencyMap;
// 의존성이 없어진 완료 인스턴스는 큐에 넣고 뽑아서 "dependencyMap" 에 자신의 패키지에 해당하는 의존성을 해결해 준다.
private Queue<CompleteObject> completeQueue;
// 해당 클래스 인스턴스에 필요한 "의존성의 수" 를 저장한다. --> Detecting 용도
// Integer 가 아닌 AtomicInteger 를 사용한 이유는, Map 의 put, get 을 한 번에 사용하는 비효율을 피하기 위함이다.
private Map<Object, AtomicInteger> dependencyTracker;
// @MyLazy 가 붙었을 경우를 상정한다.
// 일반적인 의존성을 해소 한 후, 이를 해소한다.
private Map<String, List<WaitingField>> lazyMap;
// 의존성을 해소 해 준 "완성된 컴포넌트" 는 처리가 끝난 후 컨테이너에 저장한다.
private Map<String, Object> singletonContainer;
// key : 원본 객체, value : (String, Object)
private Map<Object, CompleteObject> proxyContainer;
List<Class<?>> clazzList;
boolean isAllowCircular;
// ...
// 의존성 해결 부문의 Main 역할 메서드
public void resolveStart() {
// 먼저 클래스 리스트를 순회한다.
// 이 과정에서 프록시를 적용한다.
// 모든 클래스 메타데이터를 대상으로 순회한다.
Iterator<Class<?>> clazzIterator = this.clazzList.iterator();
while(clazzIterator.hasNext()) {
Class<?> clazz = clazzIterator.next();
// MyComponent 선언된 애너테이션인지 확인.
MyComponent isComponent = clazz.getDeclaredAnnotation(MyComponent.class);
// 만약 컴포넌트가 아니라면 건너뛴다. (스캔 대상이 아니다.)
if(isComponent == null) {
continue;
}
// 클래스 메타데이터의 패키지 이름을 가져온다. (EX - "com.damsoon.component.Component1")
String keyName = clazz.getName();
// MyAutowired 혹은 Default 생성자를 가져온다. --> Spring 과 동일한 규칙을 적용한다.
Constructor<?> constructor = this.getRequireConstructor(clazz);
// MyAutowired 된 객체의 Field 를 ParamMetadata 형태로 가공하여 반환한다. --> 추후 타입의 일치 때문.
ParamMetadata[] dependencyFields = this.getRequireFields(clazz);
// 객체 애너테이션, 생성자 파라미터 애너테이션 추출 완료.
ClassMetadata clazzInfo = this.getDataInClass(clazz, constructor);
// 지정된 생성자의 파라미터 수를 의미한다.
int paramCount = constructor.getParameterCount();
// "모든" 의존성의 수를 의미한다.
// 지정된 생성자 뿐만 아니라, 객체의 필드에서 요구하는 의존성의 수도 더하여 판단한다.
int dependencyCount = paramCount + dependencyFields.length;
// 인자가 몇 개이던, "null" 로 의존성을 채운 "가짜 의존성" 으로 채운 인스턴스를 가져온다.
Object instance = this.createInstance(paramCount, constructor, clazzInfo);
// clazz 에 붙은 애너테이션을 Mapping 했다.
// 단순히 "getDeclaredAnnotation" 으로 가져온다면, 여기서 @NonNull 에 대한 처리를 해야 했었다.
// 따라서 "ClassMetadata" 생성자에서 이를 매핑화 하는 과정이 존재한다.
Map<Class<? extends Annotation>, Annotation> clazzAnno = clazzInfo.getClazzAnnotationMap();
// 미리 이 객체에 붙어있는 프록시 정보를 가져온다. --> 코드 단축화를 위함
MyProxy proxy = (MyProxy) clazzAnno.get(MyProxy.class);
MyProxies proxies = (MyProxies) clazzAnno.get(MyProxies.class);
// 인스턴스가 생성되지마자, 프록시를 적용한다.
// proxyInstance 는 프록시 유무에 따라 instance 와 같을 수도, 다를 수도 있다.
Object proxyInstance = instance;
if(proxy != null || proxies != null) {
proxyInstance = this.proxyProcess(proxy, proxies, instance, clazz);
keyName = proxy != null ? proxy.targetInterface().getName() : proxies.targetInterface().getName();
}
// 만약 프록시가 없다면, instance 주소 == proxyInstance 주소 이며, 둘은 동일한 인스턴스이다.
// 만약 프록시가 있다면, instance 주소 != proxyInstance 주소이며, 둘은 다른 인스턴스이다.
this.proxyContainer.put(instance, new CompleteObject(keyName, proxyInstance));
// 위에서 추출된 instance 가 어떠한 의존성도 필요 없었을 경우, 곧바로 Queue 로 넣는다.(이미 완성된 객체)
if(dependencyCount == 0) {
// 등록되어야 할 패키지 문자열과 동시에, 프록시 or 원본 인스턴스를 넣어 완성한다.
CompleteObject completeObject = this.proxyContainer.get(instance);
this.completeQueue.add(completeObject);
} else {
// "생성자" 에 존재하는 인자의 데이터를 가져온다.
ParamMetadata[] paramMetadatas = clazzInfo.getParamDataList();
// "생성자" 의 의존성과, "클래스 필드" 의존성을 하나의 배열로 합친다.
List<ParamMetadata> dependencyMetadataList = this.appendList(paramMetadatas, dependencyFields);
ParamMetadata[] dependencyMetadatas = dependencyMetadataList.toArray(new ParamMetadata[0]);
// 이 객체의 필요 의존성들을 등록한다.
// registerDependency 내부에서 dependencyMap, lazyMap 에 데이터를 넣는다.
// 반환값으로는 모든 의존성을 순회하면서, 감지된 "@MyLazy" 의 개수를 반환한다.
int lazyCount = this.registerDependency(dependencyMetadatas, clazz, instance);
// 일반 의존성의 수를 구하기 위해, "모든 의존성" - "lazy 의존성" 한다.
dependencyCount -= lazyCount;
// 만약 일반 의존성이 존재하지 않는다면,
if(dependencyCount == 0) {
CompleteObject completeObject = this.proxyContainer.get(instance);
// 존재하는 의존성이 모두 Lazy 라면, 해당 의존성들은 모두 해결 된 것으로 보아야 한다.
this.completeQueue.add(completeObject);
} else {
// 필요 의존성 수를 저장하는 맵에 등록한다.
// String key 가 아니라, Object 자체로 비교하기 때문에, proxy 를 고려하지 않아도 된다.
this.dependencyTracker.put(instance, new AtomicInteger(dependencyCount));
}
}
}
}
// ...
}
코드보다 이해되는 그래프 예시
주석을 꼼꼼히 적었는데, 이해가 되지 않을 수 있다.
어쨌던 본인이 작성한 코드가 아니라, 나만의 Convention 이 들어갔기 때문이다.
- 자료구조의 세분화
- 분기문의 의도
이 2 개가 쟁정일 거라고 생각한다.
먼저, 하나의 메타데이터가 "어떻게 다양한 자료구조로 (치환)" 되는지 흐름을 작성 해 보겠다.
이를 보고 위의 코드를 보면 훨씬 이해가 쉬울 것이다.
flowchart TB
subgraph Clazz ["변수명 - clazz"]
Clazz-1("단일 클래스 메타데이터")
end
Clazz("단일 클래스 메타데이터 - clazz")
IsComponent["@MyComponent 가 붙었는가?"]
Continue1("건너뜀")
Clazz --> IsComponent
IsComponent --붙지 않음--> Continue1
ToInstance("인스턴스가 된다.")
IsComponent --붙었음--> ToInstance
Instance("인스턴스 와 클래스 메타데이터")
ToInstance --> Instance
subgraph ProxyContainer ["ProxyContainer"]
direction TB
ProxyContainer-1("구조 <br/> Map(Object, CompleteObject)")
ProxyContainer-2("변수명 : proxyContainer")
ProxyContainer-1 ~~~ ProxyContainer-2
end
subgraph ProxiedObject ["@MyProxy or @MyProxies"]
ProxiedObject-Key("Key : 원본 인스턴스 주소")
ProxiedObject-Value("Value : 프록시 인스턴스 정보")
end
subgraph OriginObject ["프록시 적용 안된 인스턴스"]
OriginObject-Key("Key : 원본 인스턴스 주소")
OriginObject-Value("Value : 원본 인스턴스 정보")
end
Instance --IF 프록시 적용--> ProxiedObject
Instance --IF 프록시 미적용--> OriginObject
ProxiedObject --저장--> ProxyContainer
OriginObject --저장--> ProxyContainer
DependencyCount{"모든 종류의 의존성 여부"}
DependencyCount1["모든 의존성이 없다면"]
DependencyCount2["어떠하든 의존성이 존재한다면"]
Instance ---> DependencyCount
DependencyCount --> DependencyCount1
DependencyCount --> DependencyCount2
subgraph CompleteQueue ["CompleteQueue"]
CompleteQueue-1("일반 의존성이 해결된 인스턴스가 들어간다.")
CompleteQueue-2("Queue(CompleteObject)")
end
DependencyCount1 --> CompleteQueue
RegisterDependency("클래스에 존재하는 모든 의존성을 등록한다.")
DependencyCount2 --> RegisterDependency
subgraph RegisterSector ["의존성 등록 자료구조들"]
subgraph DependencyMap ["DependencyMap"]
DependencyMap-1("일반 의존성 대기맵")
DependencyMap-2("Map(String, List(WaitingField))")
end
subgraph LazyMap ["LazyMap"]
LazyMap-1("@MyLazy 의존성 대기맵")
LazyMap-2("Map(String, List(WaitingField))")
end
end
RegisterDependency --일반 의존성일 경우--> DependencyMap
RegisterDependency --Lazy 의존성일 경우--> LazyMap
DependencyCount3["일반 의존성이 존재하지 않을 경우"]
DependencyCount4["일반 의존성이 존재할 경우"]
Gather("이후의 과정")
RegisterSector --> Gather
Gather --> DependencyCount3
Gather --> DependencyCount4
subgraph CompleteQueue1 ["CompleteQueue"]
CompleteQueue-3("일반 의존성이 해결된 인스턴스가 들어간다.")
CompleteQueue-4("Queue(CompleteObject)")
end
subgraph DependencyTracker ["DependencyTracker"]
DependencyTracker-1("Key : 인스턴스, Value : 필요 의존성 수")
DependencyTracker-2("각 인스턴스가 몇 개의 의존성을 필요로 하는지 기록한다.")
end
DependencyCount3 --> CompleteQueue1
DependencyCount4 --> DependencyTracker
이 흐름도를 보고, "한 번의 순회" 과정 속에서,
어떻게 다양한 자료구조로 치환되고 저장되는지 "의도" 를 알 수 있을 것이다.
여기서 CompleteObject 자료구조, registerDependency 메서드를 따로 만들어 두었는데,
이는 책임의 분리, 클린 코드를 지향하기 위함이다.
CompleteObject:String-이 인스턴스의 의존성 네임 태그-Object-인스턴스 객체registerDependencyDependencyMap,LazyMap자료구조에 의존성을 등록한다.- 반환값으로, 이 객체가 요구하는
@MyLazy,Lazy의존성 개수를 가져온다.
의존성 해결 1 단계 (일반 의존성 해소하기) Code
그래도 본격적으로 코드를 보기 시작했으니, 의존성 해소 1 단계의 코드 또한 잘 이해 할 수 있으리라 생각한다.
위에서는 코드로 표현하지 않고, "컴포넌트 예시" 를 통해 1, 2, 3 단계를 묘사했다.
이제 코드로 만나 볼 시간이다.
위에서 예시로 보여주었던 "컴포넌트 예제" 를 생각하며 보면 좀 더 쉽게 이해 할 수 있다.
public class ResolveDependency {
// 자료구조들..
// ...
public void resolveStart() {
// 위에서 실행했던 로직을 생략 - 그 직후이다.
// "의존성 해소 1 단계"
while(!this.completeQueue.isEmpty()) {
// 완성되어 있는 인스턴스 객체 하나를 큐에서 꺼낸다.
CompleteObject completeObject = this.completeQueue.poll();
// 완성 인스턴스의 전체 이름 - key
String fullName = completeObject.getFullName();
// 완성 인스턴스. - 대기중인 필드들에 꽂아줄 예정 - (원본 혹은 프록시)
Object instance = completeObject.getObject();
// 이 인스턴스를 받기 위해 대기중인 (인스턴스-필드) 배열을 추출한다.
// 일반 의존성에 해당한다.
List<WaitingField> waitingFieldList = this.dependencyMap.get(fullName);
// 일반 의존성으로서 이 인스턴스를 원하는 객체가 없다면, 바로 컨테이너에 넣는다.
// 다음 순회문으로 건너뛴다.
if(waitingFieldList == null) {
this.singletonContainer.put(fullName, instance);
continue;
}
// 여기부턴, dependencyMap(일반 의존성) 에서 자신을 기다리는 인스턴스가 존재 할 경우.
// 일반 의존성 대기 "리스트" 에서 Iterator 를 따로 추출한다.
Iterator<WaitingField> waitingIter = waitingFieldList.iterator();
// 순회하며 의존성을 넣어준다.
this.insertRealInstance(waitingIter, instance, DependencyMode.NORMAL);
// fullName(com.damsoon.component.xxx) 를 필요로 하는 모든 인스턴스에
// 의존성을 "실제로" 넣어줬으므로, 일반 의존성 대기 Map 에서 제거한다.
this.dependencyMap.remove(fullName);
// 이 의존성은 "모든 의존성" 을 만족시켜주었으므로, 진정한 컨테이너 자료구조에 들어간다.
this.singletonContainer.put(fullName, instance);
}
// 의존성 해소 2 단계
//
// 의존성 해소 3 단계
}
// ...
// 가짜 의존성으로 생성된 인스턴스가 대기 맵을 통해 하나씩 실제 의존성을 가지게 된다.
// 만약 모든 의존성이 갖춰진 인스턴스로 변모한다면, 완성 인스턴스 큐에 등록한다.
private void insertRealInstance(Iterator<WaitingField> iterator, Object realInstance, DependencyMode mode) {
while(iterator.hasNext()) {
WaitingField waitingField = iterator.next();
// 대기중인 인스턴스와 해당되는 필드를 쌍으로 저장하고 있는 형태이다.
Field targetField = waitingField.getField();
Object targetObject = waitingField.getObject();
Class<?> targetClazz = waitingField.getClazz();
String keyName = targetClazz.getName();
// 이러한 변수를 "런타임" 중에 바꿔주기 위해서는, 엄격한 규칙으로 인해
// "인스턴스" 가 중심이 아니라, "필드" 가 직접 주체가 되어 변경된다.
targetField.setAccessible(true);
// 필드 주체의 접근 상황에서 에러가 날 수 있으므로 따로 try-catch 문법으로 나눈다.
try {
targetField.set(targetObject, realInstance);
} catch(Exception e) {
System.out.println(
ColorText.red(
"[Field Access Error - IllegalAccess] : "
+ "targetObject : " + keyName
+ ", realInstance : " + realInstance.getClass().getName()
)
);
e.printStackTrace();
}
// 일반 의존성 해소(의존성 해소 1 단계) 에서만 실행된다.
if(mode == DependencyMode.NORMAL) {
// detectDependency 메서드를 통해 "타겟 인스턴스" 의 의존성이 해소되었는지 확인한다.
// dependencyTracker 자료구조에서 targetObject 에 대한 의존성 수를 -1 하고, 의존성이 남아있는지 없는지 bool 값으로 반환한다.
boolean isRemainDependency = this.detectDependency(targetObject);
// 의존성이 모두 해소되었다면, 완성 인스턴스 큐에 새로 추가한다.
if(!isRemainDependency) {
// 의존성 해소 인스턴스가 프록시 적용되어 있었다면, 해당 프록시 객체로 대신한다.
// 프록시 미적용이라면, 동일한 인스턴스가 추출된다.
CompleteObject completeObject = this.proxyContainer.get(targetObject);
this.completeQueue.add(completeObject);
}
}
}
}
}
메타데이터를 분석하며 초기화 된 자료구조들을 이용하여 의존성을 해결하게 되는데,
그 중, insertRealInstance 라는 메서드를 여기서 보여준 이유가 있다.
- 의존성 해소 1, 2, 3 단계에서 모두 사용되는 메서드이다. - 재사용 메서드
- 인자로 주는
Enumeration인DependencyMode에 따라, 분기가 나뉜다.DependencyMode.NORAML,.LAZY,.REST
- 클래스에서 "생성자 인자" 로 적어놓은 "인자 이름"과, "필드 변수 이름" 이 무조건 동일해야 한다.
- 완벽한 순환참조도 해결하는 로직을 작성하는 과정에서 결정된 "어쩔 수 없는 제약"
detectDependency를 통해, 인스턴스의 의존성 개수를 수정하고, 해소되었는지 확인한다.detectDependency에서DependencyTracker를 접근하고 수정한다.
의존성 해소 2 단계 (@MyLazy 의존성 해소하기) Code
의존성 해소 1 단계를 통해서, "일반적인 경우의 의존성" 그래프는 모두 해소되어
singletonContainer 로 들어 간 상황이다.
우리는 여기서 "사용자가 @MyLazy 를 의존성에 표기" 한 이유에 대해서 생각 해 보아야 한다.
사용자가 이 애너테이션을 의존성에 표기했다는 것은,
- 스스로 현재 구조가 순환참조임을 인지하고 있다.
- 이 순환참조 구조를 깨뜨리기 위해서
@MyLazy를 사용한다.
이다.
즉, @MyLazy TestComponent testComponent 라는 "인자" 나 "필드 변수" 가 존재했다면,
그건 개발자가
"testComponent 만 '나중에' 가져오면, 순환 참조는 해결된다." 라고 말한 것과 동일하다.
당연하겠지만, 개발자도 사람이므로, 실수 할 경우를 상정해야 한다.
public class ResolveDependency {
// 자료구조들..
// ...
public void resolveStart() {
// 위에서 실행했던 로직을 생략 - 그 직후이다.
// "의존성 해소 1 단계"
// 의존성 해소 2단계 - @MyLazy 의존성 해소
// 만약 lazy 로 등록한 필요 의존성이 singletonContainer 에 존재하지 않는다면
// 의존성 해소 3 단계 로 넘어간다.
if(!this.lazyMap.isEmpty()) {
this.resolveLazyDependency();
}
// 의존성 해소 3 단계
}
// ...
/**
* 의존성 해결 2 단계.
* @MyLazy 표기된 의존성들을 해결하는 과정이다.
*
* @MyLazy 애너테이션을 사용자가 표기했다는 것은, 이를 사용함으로서 순환참조가 해결된다는 것이다.
* 그 의미로, 의존성 해결 1 단계에서 @MyLazy 의존성 수를 "제거" 했었다.
*
* 의존성 해결 1 단계에서는 "완성된 객체" 를 Queue 에 넣고 poll 하면서 순회했다면,
* 의존성 해결 2 단계에서는 lazyMap 에 등록된 "key" 가 컨테이너에 존재한다고 가정한다.
* 따라서, lazyMap 키를 순회하며 컨테이너에서 객체를 가져온다.
*/
private void resolveLazyDependency() {
// 등록된 "모든" lazy 등록 의존성은 '일반 의존성' 이 해소 된 후에 진행한다.
Iterator<String> lazyIter = this.lazyMap.keySet().iterator();
while(lazyIter.hasNext()) {
String lazyKey = lazyIter.next();
// 완성 컨테이너에서 나중에 가져오도록 지정된 오브젝트가 완성 된 상태인지 확인한다.
Object instance = this.singletonContainer.get(lazyKey);
// 만약 의존성을 "@MyLazy" 지정해놨는데도 원하는 인스턴스가 완성되지 않았다면, 개발자가 의도하지 않은 오류에 해당한다.
// 따라서 의존성 해결 3 단계(resolveCircularDependency) 로 넘기기에,
// 이에 대한 경고를 출력한다.
if(instance == null) {
System.out.println(ColorText.red("[Exception Lazy in Dependency] : "));
System.out.println(ColorText.red("--> 아직까지도 Lazy 처리된 의존성 인스턴스가 존재하지 않음."));
System.out.println(ColorText.red("--> Dependency : " + lazyKey + " 객체가 아직도 완성되지 않음."));
System.out.println(ColorText.red("--> 해당 의존성은 강제 의존성 해결 단계에서 해소됩니다."));
continue;
}
// 원하는 인스턴스를 기다리는 여러 Field 배열
List<WaitingField> lazyList = this.lazyMap.get(lazyKey);
// 이 의존성을 원하는 필드들 모두 의존성 해소.
this.insertRealInstance(lazyList.iterator(), instance, DependencyMode.LAZY);
// lazy 에서 의존 인스턴스를 제거한다.
// this.lazyMap.remove(lazyKey); 문법의 Iterator 버전.
lazyIter.remove();
}
}
}
생각보다 간단히 이해가 될 수도 있는데,
"의존성 해결 1 단계" 는
- 의존성 해소
- 순회하기 위한 완성 인스턴스
Queue에 추가
이를 동시에 수행했다면,
@MyLazy 의존성은 말 그대로 "완성되어 있는 인스턴스" 여야만 한다.
따라서 이전에는 Queue.poll() 을 통해 완성된 인스턴스를 꺼냈다면,
이번에는 lazyMap 에 존재하는 모든 문자열(String) Key 를 통해
singletonContainer 에서 인스턴스를 찾는 것이다.
null이라면? : 개발자의 설계 실수에 가깝다.null이 아니라면? : 가져와서insertRealInstance를 통해 의존성들을 해결한다.
의존성 해결 3 단계 - (완벽한 순환참조 구조 해결)
대망의 마지막 단계에 도달했다.
어떻게 보면 이 마지막 단계야말로,
"완벽한 순환참조 구조" 까지도 받아들이겠다는 나의 프로그램 방향성을 보여주는 가장 중요한 대목이기도 하다.
public class ResolveDependency {
// 자료구조들..
// ...
public void resolveStart() {
// 메타데이터 분석 및 자료구조 안착 로직
// "의존성 해소 1 단계"
// 의존성 해소 2단계 - @MyLazy 의존성 해소
// 의존성 해소 3단계 - 모든 순환참조 의존성 해소
// 완벽한 순환참조가 발생할 경우 실행된다.
// completeQueue 는 3 단계에서 재사용된다. --> dependencyTracker 의 객체를 전부 넣는다.
// resolveCircularDependency 메서드에서 this.completeQueue 를 다시 순회하며 강제로 해소한다.
if(!this.dependencyMap.isEmpty() || !this.lazyMap.isEmpty()) {
this.resolveCircularDependency();
}
// 이 때 컨테이너는 완성된다.
}
// ...
/**
* 의존성 해결 3 단계.
* 결국 의도적이지 않은 완벽한 순환참조, 혹은 "@MyLazy" 표기했음에도 순환참조로 이어지는 모든 의존성을 해결한다.
* 만약 개발자가 "엄격 모드" 를 원한다면, 프로그램은 종료된다.
* (만약 ResolveDependency 클래스 변수의 isAllowCircular 이 FALSE 라면.
* 그러나 개발자가 순환참조 허용을 원한다면, 프로그램은 종료되지 않고 강제로 의존성을 허용한다. (Default == TRUE)
*/
public void resolveCircularDependency() {
// 사용자에게 의도적으로 "아직도 해소되지 않은" 모든 의존성을 보여준다.
System.out.println(ColorText.red("{UnResolved Dependency Detect!} --> "));
// 일반 의존성으로서 해소되지 못한 순환참조들
Iterator<String> normalIterator = this.dependencyMap.keySet().iterator();
while(normalIterator.hasNext()) {
String normalKey = normalIterator.next();
System.out.println(ColorText.yellow("UnResolve Dependency Key : " + normalKey));
}
// 사용자가 컴포넌트 의존성으로 "@MyLazy" 를 붙였는데도 불구하고, 순환참조 해결이 되지 않은 경우.
Iterator<String> lazyIterator = this.lazyMap.keySet().iterator();
while(lazyIterator.hasNext()) {
String lazyKey = normalIterator.next();
System.out.println(ColorText.yellow("UnResolve Dependency Key : " + lazyKey));
System.out.println(ColorText.yellow("--> MyLazy 의존성"));
}
// 사용자가 만약 "엄격 모드" (isAllowCircular) 를 FALSE 로 해 놓았을 경우.
if(!this.isAllowCircular) {
throw new RuntimeException("[Exception by Not Allow Circular Dependency]");
}
// 일반 의존성 중, "완벽한 순환참조" 구조에 갇힌 모든 의존성 객체를 순회한다.
Iterator<Object> restObjectIter = this.dependencyTracker.keySet().iterator();
while(restObjectIter.hasNext()) {
Object restObj = restObjectIter.next();
// 이 객체와 매칭되어 있는 Proxy 객체, 혹은 자기 자신의 주소를 가져온다.
CompleteObject completeObject = this.proxyContainer.get(restObj);
// Dead Lock 구조에 갇혀버린 인스턴스들을 넣는 과정이다.
this.completeQueue.add(completeObject);
// 이 방식으로 제거해야 Iterator 가 제거된 정보와 "동기화" 된다. --> 제거 시 이 방식이 아니면 에러가 난다.
restObjectIter.remove();
}
// 나머지 인스턴스들을 대상으로 "의존성 해결 1 단계" 와 유사한 로직을 구사한다.
// 단, 해소되지 않은 모든 인스턴스를 모두 Queue 에 넣었기 때문에, 더 이상 Queue 에 들어갈 필요가 없다.
while(!this.completeQueue.isEmpty()) {
// 순환참조 인스턴스 하나를 뽑는다.
CompleteObject completeObject = this.completeQueue.poll();
// 해당 인스턴스와 패키지 이름을 추출한다.
Object instance = completeObject.getObject();
String fullName = completeObject.getFullName();
// 자신을 기다리는 (필드-인스턴스) 쌍 배열을 추출한다.
List<WaitingField> waitingFieldList = this.dependencyMap.get(completeObject.getFullName());
// 만약 자신을 기다리는 의존성이 없다면, 최종적으로 "컨테이너" 에 들어가 완성된다.
if(waitingFieldList == null) {
this.singletonContainer.put(completeObject.getFullName(), completeObject.getObject());
continue;
}
// Iterator 를 추출한다.
Iterator<WaitingField> waitingIter = waitingFieldList.iterator();
// 순회하며 의존성을 넣어준다.
this.insertRealInstance(waitingIter, instance, DependencyMode.REST);
// fullName(com.damsoon.component.xxx) 를 필요로 하는 모든 인스턴스에
// 의존성을 "실제로" 넣어줬으므로, 대기 Map 에서 제거한다.
this.dependencyMap.remove(fullName);
// 이 의존성은 "모든 의존성" 을 만족시켜주었으므로, 진정한 컨테이너 자료구조에 들어간다.
this.singletonContainer.put(fullName, instance);
}
// 마지막으로, 사용자가 "@MyLazy" 처리를 했음에도 순환참조에 갇힌 의존성들을 해소한다.
Iterator<String> lastLazyIter = this.lazyMap.keySet().iterator();
while(lastLazyIter.hasNext()) {
// "@MyLazy" 로서 필요했던 패키지 클래스 이름을 가져온다.
String keyName = lastLazyIter.next();
// keyName 클래스 인스턴스를 원하는 배열(필드-인스턴스) 을 가져온다.
List<WaitingField> fieldList = this.lazyMap.get(keyName);
// 필요한 "모든 의존성" 은 이제 컨테이너에 들어가 있다.
Object instance = this.singletonContainer.get(keyName);
// 그럼에도 불구하고 인스턴스가 존재하지 않는다면, 이건 치명적인 에러다.
// 여러 시나리오가 있을 수 있는데, 필요한 의존성 클래스에 "@MyComponent" 애너테이션을 달지 않았을 때 발생한다.
// Dead Lock 을 뜯어서 모든 일반 의존성을 강제로 해소했기 때문이다.
if(instance == null) {
System.out.println(ColorText.red("[CRITICAL ERROR OCCURR!!!] : 의존성 해결이 완전히 불가능한 객체가 존재합니다."));
System.out.println(ColorText.red("--> 필요한 의존성 " + keyName + " 이 존재하지 않습니다."));
throw new RuntimeException();
}
// "@MyLazy" 로서 필요했던 의존성이 존재한다면, 기다리는 모든 의존성들을 해소 해 준다.
Iterator<WaitingField> fieldIterator = fieldList.iterator();
this.insertRealInstance(fieldIterator, instance, DependencyMode.LAZY);
lastLazyIter.remove();
}
// Testing - 컨테이너에 들어간 "모든" 인스턴스들을 체크한다.
Iterator<String> singletonIterator = this.singletonContainer.keySet().iterator();
while(singletonIterator.hasNext()) {
String keyName = singletonIterator.next();
Object completeInstance = this.singletonContainer.get(keyName);
System.out.println(keyName);
System.out.println("--> " + completeInstance.toString());
}
}
}
모든 의존성 구조에 상관없이 의존성을 해소 해 줄 수 있다는 것이 일종의 캐치프레이즈일 수는 있으나,
사용자에게 이를 고지조차 하지 않는다면, 사실상 스파게티 코드를 독려하는 꼴이 될 수 있다고 생각한다.
따라서, 현재 해소되지 못한 의존성들은 무엇이 있는지 알려주고,
- 일반 의존성 Dead Lock 해제 및 의존성 해소
- 마지막으로 개발자의 실수로 인해 해소되지 못한
@MyLazy의존성도 해소한다. - 컨테이너에 들어간 "모든" 컴포넌트를 출력한다.
마지막 컴포넌트 테스팅.
@MyComponent
public class Component1 {
Component2 component2;
// 순환 참조 상태. Component2 에서 @MyLazy 선언을 해 준 상태.
@MyAutowired
public Component1 (Component2 component2) {
this.component2 = component2;
}
}
// ---
@MyComponent
public class Component2 {
Component1 component1;
@MyAutowired
Component3 component3;
@MyAutowired
public Component2 (@MyLazy Component1 component1) {
this.component1 = component1;
}
}
// ---
interface Component3 {
public void testProxy();
}
@MyComponent
@MyProxy(proxy = ExecutionTime.class, targetInterface = Component3.class)
public class Component3Impl implements Component3 {
Component4 component4;
@MyAutowired
public Component3Impl(Component4 component4) {
this.component4 = component4;
}
public void testProxy() {
System.out.println("Method in Component3");
}
}
// ---
@MyComponent
public class Component4 {
Component3 component3;
@MyAutowired
public Component4 (@MyLazy Component3 component3) {
this.component3 = component3;
}
}
// ---
@MyComponent
public class Component5 {
Component6 component6;
@MyAutowired
public Component5(Component6 component6) {
this.component6 = component6;
}
}
// ---
@MyComponent
public class Component6 {
Component5 component5;
public Component6(Component5 component5) {
this.component5 = component5;
}
}
Shell
# 기존 컴파일 결과를 지우고, 다시 생성하기 위함.
➜ test-1 git:(main) rm -rf result
# 만들어 둔 shell 명령어 파일 실행
➜ test-1 git:(main) ✗ ./compile-and-execute.sh
컴파일 & 실행 구문 시작
com.damsoon # 메타데이터 읽는 시작점.
# 시작점으로부터 존재하는 "모든" 클래스 메타데이터 읽기
메타데이터 확인 절차 시작
com.damsoon.proxy -- ExecutionTime
com.damsoon.util -- ResolveDependency
com.damsoon.util -- ResearchPackage
com.damsoon.util.type -- ParamMetadata
com.damsoon.util.type -- ClassMetadata
com.damsoon.util.type -- WaitingField
com.damsoon.util.type -- CompleteObject
com.damsoon.util.type -- ProxyInfo
com.damsoon.util -- DependencyMode
com.damsoon.util.console -- ColorText
com.damsoon.component -- Component3Impl
com.damsoon.component -- Component2
com.damsoon.component -- Component6
com.damsoon.component -- Component4
com.damsoon.component -- Component3
com.damsoon.component -- Component1
com.damsoon.component -- Component5
com.damsoon.annotation -- MyProxies
com.damsoon.annotation -- MyAutowired
com.damsoon.annotation -- MyLazy
com.damsoon.annotation -- MyComponent
com.damsoon.annotation -- MyProxy
com.damsoon.container -- CustomContainer
com.damsoon -- Main
# 의존성 해결 1 단계 실행 후, 컨테이너에 들어가 있는 인스턴스 목록
현재 싱글톤에 들어있는 완성 인스턴스 이름 : com.damsoon.component.Component3
현재 싱글톤에 들어있는 완성 인스턴스 이름 : com.damsoon.component.Component2
현재 싱글톤에 들어있는 완성 인스턴스 이름 : com.damsoon.component.Component1
현재 싱글톤에 들어있는 완성 인스턴스 이름 : com.damsoon.component.Component4
# 의존성 해결 2 단계 실행 중, Lazy 의존성이 무엇이었는지 출력.
lazyKey : com.damsoon.component.Component3
lazyKey : com.damsoon.component.Component1
# 의존성 해결 3 단계 - 미해결 순환참조 의존성 출력 후, 모든 의존성 해결.
{UnResolved Dependency Detect!} -->
UnResolve Dependency Key : com.damsoon.component.Component6
UnResolve Dependency Key : com.damsoon.component.Component5
# 테스팅 과정에서 "toString" 을 모든 인스턴스에 행한 결과.
com.damsoon.component.Component3
proxy 실행됨
시작과 종료에 걸린 시간은 : 0ms, OR : 182334ns
--> com.damsoon.component.Component3Impl@3d646c37
com.damsoon.component.Component2
--> com.damsoon.component.Component2@5a10411
com.damsoon.component.Component1
--> com.damsoon.component.Component1@68de145
com.damsoon.component.Component6
--> com.damsoon.component.Component6@2ef1e4fa
com.damsoon.component.Component5
--> com.damsoon.component.Component5@27fa135a
com.damsoon.component.Component4
--> com.damsoon.component.Component4@306a30c7
컴파일 & 실행 구문 종료
ResolveDependency 클래스 파일의 "모든 코드"
"의존성 해결" 에 초점을 맞추기 위해
이 목적을 보조하는 Method 들을 따로 펼쳐놓지 않았다. (펼치면 너무 난잡해져서..)
모든 코드를 보고 이해를 원하는 분들을 위해, GitHub 주소를 드리는 것이 맞다고 생각했다.
직접 만든 Util 자료구조들도 있는데, 깃허브에서 상세하게 확인이 가능하다.
컨테이너의 로직을 전부 완성하였다.
이 글을 작성하게 된 계기는 생각보다 단순했다.
인터넷 강의를 통해 "Spring" 을 배우고 있는데,
정작 가장 중요해 보이는 "Spring Container" 에 대해서 뭉퉁그려 설명하고 넘어갔다.
"Spring Container 는 어려우니 간단히 설명하고 넘어가는게 맞지 않나?"
당연히, Spring 강의에서 Spring Container 에 대해서 간단히 설명하고 넘어가는 것이 맞다.
그러나, 나는 "내가 직접 컨테이너를 만들어 보겠다" 라는 생각이 뇌를 지배했다.
이 편한 프레임을 만들게 해준 컨테이너를 만드는 것이 "생각보다 쉬워 보였기 때문" 이었다.
결과적으로 보자면, 절대 쉽지 않았다.
이 주제로 글을 작성하는 사람이 없었다.
포기하지 않고 글을 완료하게 해 준 이유이기도 한데,
나만의 Custom Container 를 만드는 사람이 없기도 하고,
전부 컨테이너의 "동작 원리" 만을 설명하는 사람들이었다.
사실 이 글의 수도 그리 많지는 않았다.
그렇다면, "직접 컨테이너를 만드는 것" 은, 더 특별하지 않을까?
그리고 나의 성장 에 지대한 영향을 끼칠 것이라고 생각했다.
마지막 요약 및 제약
직전 "요약" 부분에서 지금까지의 내용은 결국 ResolveDependency 에 관한 내용이다.
그리고 가장 중요한 부분 또한, ResolveDependency 이다.
"모든 것은 결국 Class 가 된다."
특히 Java 에서 까먹지 말아야 할 중요한 포인트라고 생각한다.
그리고 Metadata 를 활용하여 Java Class 를 런타임에서 분석할 생각을 한다면,
이 문장은 더더욱 잊지 말아야 한다.
모든 클래스 메타데이터를 분석하여 인스턴스화 한다는 것은,
인스턴스화 하기 위한 클래스 메타데이터를 무조건 필요로 한다는 말이다.
타 언어와 다르게, Java 는 JVM 의 ClassPath 를 기준으로,
패키지 이름으로 클래스 메타데이터를 탐색 할 수 있다.
무작정 ../xxx.class 로 불러 올 수 없다.
정확한 패키지 절대경로명, com.damsoon.component.xxx 로 불러올 수 있다.
이는 "클래스 파일" 에 대한 설명이고,
만약에 파일 탐색 및 처리를 "문자열 단위" 로 한다면, 클래스 파일을 탐색하는 것은 아니므로,
"상대경로" 를 사용 할 수 있을 것이다.
프레임워크는 사용자의 컴포넌트, 혹은 유틸 "클래스명" 을 미리 알 수 없다.
따라서, Class<?> 와 같은 메타데이터 클래스로 분석할 수 밖에 없다.
만약에 getDeclaredAnnotation() 과 같은 내장 메서드를 이용하여 클래스를 추출한다면,
Class<? extends Annotation> 과 같은 "Generic" 형태로 세분화 할 수 있다.
모든 클래스 자체의 데이터는 Class<?> 이며,
모든 클래스에서 "탄생한 인스턴스" 는 Object 를 띈다는 것을 기억해야 한다.
이 글을 작성하면서 느낀 것.
이번 글은 내가 작성 한 글 중 "가장 어렵고 난해한" 글이다.
앞으로 더 어려운 글을 적겠지만,
적어도 "어떤 언어에서든," 메타프로그래밍을 작성하기 위한 초안 정도는
설명을 잘 해 놓았다고 생각한다.
Spring 이 잘 만들어진 프레임워크라서, 단순히 Spring 의 사용처를 잘 아는 것 만으로도
System Architecture 를 이해하고 적용 할 수 있지만,
Spring 자체의 그 조그마한 보안 취약점도 허용하지 못하여,
사내의 Framework 를 제작 할 수도 있다고 생각한다.
이 때 나의 글이 "영감" (Idea) 를 줄 수 있지 않을까? 생각한다.
이 글은 나의 성장이자 도전이었다.
누군가 이 글을 읽고 도움이 되었다면, 나는 너무나도 만족한다.
그리고 앞으로 작성하게 될 나의 글이 또다시 누군가에게 큰 도움이 되길 바란다.
특정 독자들을 유추할 수 없지만,
요즘 블로그에 Perplexity (퍼플렉시티) 라는 AI 가 나의 글을 많이 참조하는 것을 발견한다.
이 또한 누군가 나의 정보를 원하여, AI 가 가공하여 제공하는 것이라고 생각한다.
과정만 다를 뿐, 누군가에게 큰 도움이 된다는 것은 다를 여지가 없다.
앞으로도 좋을 글을 작성하며, 성장하고,
내가 만든 프로그램이 나중에 많은 사람들에게 도움이 되기를 빈다.
참조 사이트
위키백과 - 메타프로그래밍
https://ko.wikipedia.org/wiki/%EB%A9%94%ED%83%80%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D
Oracle Java Documentation- Examining Class Modifiers and Types
https://docs.oracle.com/javase/tutorial/reflect/class/classModifiers.html
개발자 블로그 - cp 옵션 와일드카드
https://blog.naver.com/cardano_null/222115578045
IBM 공식문서 - Java 클래스 경로
https://www.ibm.com/docs/ko/i/7.5.0?topic=usage-java-classpath
위키백과 - 메타프로그래밍
https://ko.wikipedia.org/wiki/%EB%A9%94%ED%83%80%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D
Oracle - ClassLoader
https://docs.oracle.com/javase/8/docs/api/java/lang/ClassLoader.html
Oracle - Class
https://docs.oracle.com/javase/8/docs/api/java/lang/Class.html
위키백과 - 자바 애너테이션
https://ko.wikipedia.org/wiki/%EC%9E%90%EB%B0%94_%EC%95%A0%EB%84%88%ED%85%8C%EC%9D%B4%EC%85%98
Oracle Document - (Annotations)
https://docs.oracle.com/javase/tutorial/java/annotations/
'Spring' 카테고리의 다른 글
| IoC, DI, DIP 개념을 Spring & Code 로 알아보자 (0) | 2025.11.15 |
|---|