결합력이란 한 컴포넌트가 다른 컴포넌트에 얼마나 의존하는가를 나타내는 개념이다. 결합력이 강할수록 각 컴포넌트는 밀접하게 연결되고, 결합력이 약할수록 각 컴포넌트는 독립적으로 동작할 수 있다. 느슨한 결합은 객체들 간의 의존성을 최소화하는 것을 의미하며, 이를 통해 변경이 일어났을 때 하나의 객체만 수정하면 다른 객체들은 그대로 사용할 수 있게 된다.
인터페이스는 느슨한 결합을 실현하는 주요 도구이다. 인터페이스는 구체적인 구현체와 상관없이 서로 다른 컴포넌트들이 통신할 수 있게 해준다. 이를 통해 변경에 유연하게 대처할 수 있는 구조를 제공한다.
엔터프라이즈 애플리케이션은 기본적으로 서비스 레이어와 데이터 액세스 오브젝트(DAO) 레이어로 나누어진다. 서비스 레이어는 사용자의 비즈니스 요구에 맞는 서비스와 로직을 처리하는 역할을 한다. DAO는 데이터 소스에 접근하는 방법을 추상화하여, 서비스 레이어에서는 구체적인 구현 기술을 몰라도 데이터를 쉽게 접근할 수 있게 해준다.
코드 수정 없이 결합력을 줄이기 위한 설계는 DI(Dependency Injection) 기법을 사용하여 가능하다. 예를 들어, UI에서 요청이 들어오면 Service 레이어가 DAO를 통해 데이터에 접근하는 흐름이 형성된다. 이때 Service 객체가 DAO 객체와 결합되어 있지만, 인터페이스를 사용하면 Service가 DAO의 구체적인 구현에 의존하지 않고 인터페이스만을 의존하게 되어 결합력을 줄일 수 있다.
프로그램은 시간이 지남에 따라 요구사항이 변경되거나 기능 개선이 필요하여 수정이 뒤따르게 된다. 예를 들어, B1 객체의 알고리즘이 변경되거나, 서비스에 대한 요구사항이 달라져서 이 객체를 수정해야 하는 상황이 발생할 수 있다.
이때 첫 번째로 생각할 수 있는 방법은 B1 객체의 소스 코드를 직접 수정하는 것이다. 소스 코드를 찾아서 필요한 부분을 수정하고, B1 객체의 동작을 변경된 요구사항에 맞게 바꾸는 방식이다. 이는 간단하게 들릴 수 있지만, 코드 수정으로 인한 결합 문제가 발생할 수 있다. 특히, 다른 컴포넌트들과의 의존성이 클 경우, B1 객체의 수정이 전체 시스템에 영향을 미칠 수 있다.
두 번째 방법은 B1 객체를 그대로 두고 B2라는 새로운 객체를 만들어 덮어쓰는 것이다. 이렇게 덮어쓰기 방식으로 처리하면 S가 B1 대신 B2를 사용하게 된다. 하지만 여기서 문제는, S 객체가 B1과 결합되어 있기 때문에, 단순히 B2 객체를 추가한다고 해서 S를 수정하지 않고 사용할 수 있는 것이 아니다. B2를 사용하기 위해서는 S의 코드 역시 수정되어야 한다는 문제가 발생한다.
현재 S는 B1이라는 자료형을 참조하고 있으며, B1 객체를 생성하고 있다. 그러나 B2로 변경하려면, S는 B2 자료형을 참조하고 B2 객체를 생성해야 한다. 이러한 덮어쓰기 방식은 소스 코드를 수정할 필요가 없다는 장점이 있지만, S 객체의 수정이 필요한 점에서 아직까지 결합력이 높은 상태이다.
따라서 S와 B2 간의 결합력을 낮추는 방법을 고민해봐야 한다. 즉, 소스 코드를 수정하지 않고 S를 바꿀 수 있는 구조가 필요하고 소스 코드 없이 S를 바꿀 수 있으면 시스템의 결합력이 낮아졌다고 볼 수 있다.
우선 B1 또는 B2와 같은 구체적인 자료형을 사용하는 대신, B라는 공통의 자료형을 사용할 것이다.
private B b = new B1();
b.getData()
private B b = new B2();
b.getData()
이 코드에서 B1과 B2는 내부적으로 다른 구현을 가지고 있지만, S가 사용하는 서비스 자체는 변경되지 않는다. 즉, B2는 B1의 개선된 버전이지만, 서비스의 본질적인 기능은 동일하다. 따라서 구체적인 클래스 이름을 사용하는 대신, B와 같이 기능에 초점을 맞춘 자료형을 사용하는 것이 더 좋다.
이것이 바로 인터페이스의 개념이다. 인터페이스를 사용하면, 구체적인 구현체(예: B1, B2)와는 상관없이 동일한 방식으로 객체를 사용할 수 있다. 이를 통해 코드를 더욱 유연하고 결합력을 낮춘 상태로 유지할 수 있다.
인터페이스를 사용하는 것이 더 바람직한 이유는 결합력을 낮추기 때문이다. 여기서 사용되는 자료형은 B1이나 B2가 아니라 B라는 인터페이스형이다. S 객체는 B 인터페이스를 구현하는 객체라면 어떤 객체든 주입할 수 있다. 즉, B1을 주입할 수도 있고, B2를 주입할 수도 있다.
아래 코드에서 볼 수 있듯이, B1이든 B2든 B라는 인터페이스로 참조하게 되면, S와 B 간의 결합력이 낮아진다.
B b = new B2();
service.setB(b);
하지만 여기서 문제는 new B2();와 같은 객체 생성 부분이다. 인터페이스를 사용한다고 해도 B1 객체를 생성하려면 B b = new B1();으로 B2 객체를 생성하려면 B b = new B2();으로 구현체 부분을 수정해 줘야 하는 문제가 여전히 있다.
이 문제를 해결하기 위해 Service와 Dao의 결합을 외부에서 처리하게 된다. UI가 B1을 생성하여 결합하는 방식에서 B2로 변경하는 작업은 소스 코드를 직접 수정하지 않고 외부 설정을 통해 이루어질 수 있다.
소스 코드에서 new B1();과 같은 객체 생성을 코드 내부에 두는 대신, 외부 설정 파일(XML 또는 애노테이션)에서 관리하여 필요에 따라 B1을 B2로 쉽게 교체할 수 있도록 하는 것이 핵심이다. 이로써 결합력을 낮추고, 수정과 유지보수가 용이해진다.
오늘 배운 내용을 정리해보면 기업형 애플리케이션을 만들게 될 때 다양한 레이어를 만들게 되는데 이를 유지보수하는 과정에서 소스 코드를 직접 수정할 수도 있다. 그러나 소스 코드를 직접 수정하고 배포하는 방식은 위험성이 따르며, 항상 소스 코드에 접근할 수 있어야 하는 부담이 있다. 이를 대신하여 대체하는 방법이나 추가하는 방식으로 유지보수를 할 수 있는데, 이 과정에서 설정 파일이 필요하다. 스프링은 이러한 결합을 도와주는 설정 파일을 제공하고, 객체를 결합하는 역할을 한다.
추가로 외부 설정 파일(XML 또는 애노테이션)에서 관리하여 필요에 따라 구현체를 쉽게 교체할 수 있도록 하는원리를 알고싶은 사람은 아래의 글을 추가로 읽기바란다.
이전 시간에는 B b = new B(); 이렇게 A가 B를 직접 사용했기 때문에, 두 객체 사이의 결합력이 매우 강했다. A가 B에 의존적이었기 때문에 B의 변화가 있을 경우 A도 함께 수정해야 하는 상황이 발생했다.
하지만, A가 B를 직접 참조하지 않고 X x = new B();와 같이 X라는 인터페이스를 사용하 A는 이제 X에만 결합되어 있으며, B는 X를 구현한 객체 중 하나로 동작한다. 이러한 방식으로 A와 B의 결합력이 없어져, B가 변경되더라도 A는 수정할 필요가 없어진다.
만약 C라는 객체가 새로 만들어졌고, 이 C도 X 인터페이스를 구현하고 있다면 A는 B뿐만 아니라 C와도 결합될 수 있다. 즉, X 인터페이스를 통해 A는 B와 C를 선택적으로 사용할 수 있는 구조가 된다.
하지만 문제는, 기존에 B를 사용하고 있던 상황에서 C로 대체하고 싶을 때 X x = new B()에서 X x = new C()로 소스 코드를 수정해야만 한다는 것이다.
코드를 보며 이해해보자, 아래의 그림을 보면, B 객체를 생성하여 A 클래스의 setX() 메서드에 주입하고 있다. 그러나 나중에 C라는 더 개선된 클래스가 만들어졌다고 가정하면 C가 X 인터페이스를 구현하고 있더라도, B를 C로 교체하려면 다음과 같이 소스 코드를 수정해야 한다.
X x = new B(); -> X x = new C();
a.setX(x);
이처럼 B에서 C로 교체하려면 소스 코드를 직접 수정하고, 다시 빌드한 후 배포해야 한다. 이는 유지보수 측면에서 비효율적이고, 실무에서 지속적인 소스 코드 수정은 문제가 될 수 있다.
일반적으로 프로그램에서 객체 간의 결합을 유연하게 관리하기 위해 설정 파일을 사용한다. 이 설정 파일을 통해 B 클래스나 C 클래스를 사용하도록 지정할 수 있다. 즉, 설정 파일에서 B 클래스를 사용하라고 명시하면 B 클래스와 결합되고, C 클래스를 사용하라고 설정을 바꾸면 C 클래스로 결합할 수 있다.
이렇게 변경이 필요한 부분은 설정 파일로 분리하여 관리하는 것이 기본적인 방식이다. 설정 파일은 크게 두 가지 방식으로 구현할 수 있다. 첫 번째는 XML 형태와 같은 외부 설정 파일을 사용하는 방식이고 두 번째는 애노테이션을 사용하여 클래스에 주석처럼 설정 정보를 부여하는 방식이다. 애노테이션은 컴파일 시에도 남는 메타데이터로, 설정 파일 대신 클래스 자체에 결합 설정을 포함시킬 수 있다.
현업에서 많이 사용되는 방식 중 하나는 XML 형태의 설정 파일이다. 또는 JSON과 같은 다른 형태로도 설정할 수 있지만, 오늘은 간단하게 txt 외부 파일을 통해 설정을 변경하여 B 클래스와 C 클래스를 교체하는 방식을 알아보자.
다시 이전에 봤던 코드를 보면 현재 문제는 B b = new B();와 같은 코드에서 B 객체를 C로 바꾸고 싶다는 점이다. 만약 C 클래스를 사용하려면, C b = new C();로 바꾸어 C 객체를 생성해야 하고, 이를 a.setX(b)에 전달해야 한다. 하지만, C를 사용하는 방식이 되면 나중에 D가 생겼을 때, 다시 X x = new D();로 바꿔야 하는 번거로움이 생긴다.
이러한 문제를 해결하기 위해, 자료형은 인터페이스형으로 설정하고, 구체적인 객체 생성을 외부에서 읽어오는 방식으로 처리할 수 있다.
문제는 new C();와 같은 구체적인 클래스 생성 부분이다. 이 부분을 소스 코드를 수정하지 않고 B에서 C로, 그리고 D로 쉽게 변경할 수 있는 방법이 필요하다. 이를 위해 외부 설정 파일을 이용하여 X x = ?;에서 ? 부분을 외부 설정에 따라 바꿀 수 있도록 한다. 즉, 설정 파일을 통해 new C();를 외부에서 읽어와 X 인터페이스형 변수에 할당할 수 있다.
객체 생성에 대한 정보를 외부 설정 파일로 관리하기 위해 예시로 setting.txt 파일을 만들어보자. 그리고 이 txt파일에 만들고 싶은 객체의 패키지명과 클래스명을 기록한다. 만약 다른 클래스를 사용하고 싶다면, B 대신 해당 클래스의 패키지명과 클래스명을 기록하면 된다.
그리고 setting.txt 파일에서 현재 설정돼 있는 이 내용을 읽어오기 위해서 먼저, FileInputStream을 사용하여 setting.txt 파일을 읽는다.
FileInputStream fis = new FileInputStream("src/part3/ex6/인터페이스/setting.txt");
그 후 Scanner를 사용하여 파일 내용을 읽어온다. Scanner는 파일로부터 데이터를 읽어오기 위해 적합한 클래스다. 다음 코드를 통해 설정 파일에 적힌 클래스 이름을 한 줄씩 읽어올 수 있다.
Scanner scan = new Scanner(fis);
String className = scan.nextLine();
읽어온 클래스 이름을 출력하여 확인해보자. 아래의 그림과 같이 잘 출력되는것을 확인할 수 있다. 이렇게 setting.txt 파일에서 설정한 클래스 이름을 읽어오는것 까지 성공했다.
이제 읽어온 클래스 이름을 바탕으로 객체를 생성하는 과정이 필요하다. 그러면 X x = new className; 이렇게 생성해도 될까? className은 변수인데 setting.txt에서 읽어온 문자열이다.
즉 X x = new "part3.ex6.인터페이스.B";로 변환되어 문자열로 읽어온 클래스 이름을 단순히 new className;과 같이 사용할 수는 없을 것이다.
그 후, 객체를 생성해야하는데 자바에서 객체를 생성하는 방법은 new 연산자를 사용하는 방법뿐만 아니라 newInstance() 메서드를 통해서도 가능하다. new 연산자는 정적인 객체 생성 방식이며, newInstance()는 주로 클래스 이름이 런타임에 결정되거나 동적으로 생성해야 할 때 사용한다.
즉, new A()로 객체를 생성할 수 있는 것처럼 Class.forName()을 통해 클래스 정보를 얻고, newInstance() 메서드를 사용하여 동적으로 객체를 생성할 수 있다.
Class.forName() 메서드를 사용하면, 문자열로 주어진 클래스 이름을 기반으로 해당 클래스의 정보를 동적으로 로드할 수 있다. 이 메서드는 클래스 정보를 가져오는 Class 구조체를 반환하며, 이를 통해 객체를 생성하거나 클래스의 메타데이터를 활용할 수 있다. 예를 들어, Class.forName(className)은 괄호안의 클래스의 정보를 로드한다.
Class 구조체란?
Class 구조체는 특정 클래스에 대한 메타데이터를 포함하는 객체다. Class 객체를 통해 해당 클래스의 메서드, 필드, 생성자 등 여러 가지 정보를 조회할 수 있으며, 이를 활용해 동적으로 객체를 생성하거나, 메서드를 호출하는 등의 작업을 수행할 수 있다.
클래스의 정보를 로드한 후, newInstance() 메서드를 사용하여 로드한 클래스 정보를 기반으로 객체를 생성한다.
이제 setting.txt에서의 수정만으로 코드 수정없이 객체를 변경할 수 있는지 확인해보자. part3.ex6.인터페이스.B를 part3.ex6.인터페이스.C로 변경하여도 객체가 잘 생성되는것을 확인할 수 있다.
참고자료
'🖥️ Backend > Spring' 카테고리의 다른 글
[Spring] 6.스프링 DI 설정을 위해 이클립스 플러그인 설치하기 (3) | 2024.10.20 |
---|---|
[Spring] 5.Spring없이 Dependency를 직접 Injection하기 (0) | 2024.10.08 |
[Spring] 4.IoC(Inversion Of Control) 컨테이너 (1) | 2024.10.02 |
[Spring] 3.DI(Dependency Injection) (0) | 2024.10.01 |
[Spring] 1.Spring 소개와 학습 안내 (1) | 2024.09.27 |