마크다운으로 작성한걸 html로 변환해주는거로 만들었더니 좀 생긴게 아쉽네요. 분명 제가 보여준곳에선 이정돈 아니었는데... 아쉽군요
지난번과 마찬가지로, 아쉬운 부분이나 문제있어서 고쳤으면 싶은 부분 말씀해주시면 큰 도움이 됩니다.
항상 감사드립니다
천원돌파 의존성 역전
목차
- 요약
- 문제 상황
- 의존성 역전을 통한 개선
- 다양한 방식의 의존성 역전
- 마치며
요약
- 의존성 역전 패턴은 객체지향 프로그래밍의 핵심 원리 중 하나로, 객체 간의 결합도를 낮추고 유연성을 높이는 방법입니다.
- 의존성 역전은 상위 모듈이 하위 모듈을 추상화한 인터페이스나 추상 클래스에 의존하도록 하는 것을 말합니다.
- 제어 역전을 통해 필요한 의존 객체의 생성부터 사용, 생명주기 관리까지 모든 것을 외부에서 관리하도록 하는 것도 가능합니다.
문제 상황
실제로 이런 끔찍한 사례를 마주하지 않으면 더 좋겠지만, 만약 우리에게 다음과 같은 억지스러운 프로그램을 작성해야하는 상황이 닥쳤을 때를 한 번 생각해보겠습니다. 우리에게는 특정 바이너리 파일을 읽고 이 파일에 1이 얼마나 들어가 있는지 세는 프로그램을 만들라는 요구사항이 주어졌습니다. 그렇다면 다음과 같은 코드를 작성할 수도 있을 겁니다.
class BinaryFileManager:
def open_file(self, file_name):
self.file = open(file_name, 'rb')
def read_file(self):
return self.file.read().decode('utf-8', 'ignore')
class DataAnalyzer:
def __init__(self):
self.file_manager = BinaryFileManager()
def process_data(self, file_name):
self.file_manager.open_file(file_name)
self.analyze_data()
def analyze_data(self):
data = self.file_manager.read_file()
if data:
ones_count = self.count_ones(data)
print(f"Number of ones in the file: {ones_count}")
else:
print("No file open.")
def count_ones(self, data):
return data.count('1')
그런데 만약에 나중에 요구사항이 변화해서, 바이너리 파일에서 1의 갯수를 세는 것 만이 아니라 텍스트 파일에서 1의 갯수를 세는 것도 요구사항으로 추가된다면 어떻게 될까요? 어쩌면 우리는 다음과 같은 끔찍한 코드를 작성해야할 수도 있을겁니다.
class BinaryFileManager:
def open_file(self, file_name):
self.file = open(file_name, 'rb')
def read_file(self):
return self.file.read().decode('utf-8', 'ignore')
class TextFileManager:
def open_file(self, file_name):
self.file = open(file_name, 'r')
def read_file(self):
return self.file.read()
class DataAnalyzer:
def __init__(self, file_type):
if file_type == 'Binary':
self.file_manager = BinaryFileManager()
elif file_type == 'Text':
self.file_manager = TextFileManager()
def process_data(self, file_name):
self.file_manager.open_file(file_name)
self.analyze_data()
def analyze_data(self):
data = self.file_manager.read_file()
if data:
ones_count = self.count_ones(data)
print(f"Number of ones in the file: {ones_count}")
else:
print("No file open.")
def count_ones(self, data):
return data.count('1')
우리는 단순히 DataAnalyzer 객체에서 사용할 새로운 클래스를 추가했을 뿐인데, DataAnalyzer 내부의 메서드 역시도 구현을 변환해야했습니다. 만약 이후에 새로운 파일 타입이 추가된다면, 또 다시 DataAnalyzer 내부의 메서드를 수정해야할 것입니다. 이런 상황에서 우리는 어떻게 해야할까요?
의존성 역전을 통한 개선
from abc import ABC, abstractmethod
class IFileManager(ABC):
@abstractmethod
def open_file(self, file_name):
pass
@abstractmethod
def read_file(self):
pass
class BinaryFileManager(IFileManager):
def open_file(self, file_name):
self.file = open(file_name, 'rb')
def read_file(self):
return self.file.read().decode('utf-8', 'ignore')
class TextFileManager(IFileManager):
def open_file(self, file_name):
self.file = open(file_name, 'r')
def read_file(self):
return self.file.read()
class DataAnalyzer:
def __init__(self, file_manager: IFileManager):
self.file_manager = file_manager
def process_data(self, file_name):
self.file_manager.open_file(file_name)
self.analyze_data()
def analyze_data(self):
data = self.file_manager.read_file()
if data:
ones_count = self.count_ones(data)
print(f"Number of ones in the file: {ones_count}")
else:
print("No file open.")
def count_ones(self, data):
return data.count('1')
개선한 코드는 Binary와 text 형태를 동시에 처리할 수 있게 사양이 변화됐는데도, 상위 모듈인 DataAnalyzer의 내부 구현은 영향을 받지 않았습니다. 이처럼 소프트웨어 간의 모듈이 상대에게 의존하게 될 때, 상대의 세부 구현이 변하더라도 상대적으로 적은 영향을 받도록 추구하는 패턴의 프로그래밍 작성 방식 중 하나를 우리는 의존성 역전이라 부릅니다.
의존성 역전은 구체적으로 상위 모듈이 하위 모듈에 의존하는 상황에서 그것의 구체적인 구현에 의존하지 않고, 하위 모듈을 추상화한 상위 모듈에 의존하도록 하는 것을 말합니다. 여기에서 말한 하위 모듈을 추상화한 상위 모듈이란 위의 예시에서 본 IFileManager와 같은 인터페이스, 혹은 추상 클래스들을 말합니다. 다른 예시를 들어 살펴보자면 컴퓨터와 주변 기기들을 다음과 같이 작성할 수 있을 것입니다.
import java.util.ArrayList;
interface PeripheralDevice {
void connect();
}
class Mouse implements PeripheralDevice {
@Override
public void connect() {
System.out.println("Mouse is connected.");
}
}
class Keyboard implements PeripheralDevice {
@Override
public void connect() {
System.out.println("Keyboard is connected.");
}
}
class Computer {
private ArrayList peripheralDevices;
public Computer() {
this.peripheralDevices = new ArrayList<>();
}
public void start() {
System.out.println("Computer is starting...");
}
public void addPeripheral(PeripheralDevice peripheralDevice) {
peripheralDevices.add(peripheralDevice);
}
public void connectPeripherals() {
for (PeripheralDevice peripheralDevice : peripheralDevices) {
peripheralDevice.connect();
}
}
}
public class Main {
public static void main(String[] args) {
PeripheralDevice mouse = new Mouse();
PeripheralDevice keyboard = new Keyboard();
Computer desktop = new Computer();
desktop.addPeripheral(mouse);
desktop.addPeripheral(keyboard);
desktop.start();
desktop.connectPeripherals();
}
}
위에서 구현한 자바 코드는 컴퓨터를 OOP의 형태로 표현한 것입니다. 컴퓨터에는 다양한 주변기기를 연결할 수 있습니다. 그리고 그 주변기기의 동작에는 여러가지 형태가 존재하고, 앞으로도 새로운 주변기기가 생성될 것이니 interface라는 추상화된 상위 모듈에 구현을 의존한다면 더 유연하게 추가 구현이 발생할 때 대응할 수 있습니다.
의존성 역전 패턴을 활용하면 새로운 저수준의 모듈을 구현에 추가하려 할 때 뿐 아니라, 저수준 모듈의 구현이 변했을 때 이에 대응할 수 있다는 장점도 가지고 있습니다.
이제 예시를 통해 의존성 역전의 장점을 설명해보겠습니다.
기존에는 UserRepository 객체가 MySQL 데이터베이스에 저장하는 로직을 구현했다고 가정해봅시다. 그런데 MySQL이 유료화가 된다거나 심각한 보안 문제가 있는 것이 발견돼서 PostgreSQL 데이터베이스를 사용하도록 변경해야하는 상황을 맞이했습니다. 만약 기존 UserRepository가 다음과 같이 데이터베이스에 종속적이라면, 기존의 UserRepository 객체를 수정해야 할 것입니다.
public class UserRepository {
public void save(User user) {
try {
MysqlDataSource dataSource = new MysqlDataSource();
dataSource.setUrl("jdbc:mysql://localhost:3306/mydatabase");
dataSource.setUser("username");
dataSource.setPassword("password");
Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement("INSERT INTO users (username, email) VALUES (?, ?)");
statement.setString(1, user.getUsername());
statement.setString(2, user.getEmail());
statement.executeUpdate();
connection.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
public class UserRepository {
public void save(User user) {
try {
PGSimpleDataSource dataSource = new PGSimpleDataSource();
dataSource.setUrl("jdbc:postgresql://localhost:5432/mydatabase");
dataSource.setUser("username");
dataSource.setPassword("password");
Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement("INSERT INTO users (username, email) VALUES (?, ?)");
statement.setString(1, user.getUsername());
statement.setString(2, user.getEmail());
statement.executeUpdate();
connection.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
[위는 기존에 MySQL을 사용하다가 PostgreSQL을 사용하도록 변경한 코드입니다. 이렇게 변경하면 UserRepository 객체의 구현이 변경되어야 하므로 의존성 역전 패턴을 활용하지 않은 것입니다.]
하지만 의존성 역전 패턴을 활용하면 UserRepository 객체가 Database 인터페이스에 의존하도록 하고, MySQLDatabase와 PostgreSQLDatabase 클래스가 Database 인터페이스를 구현하도록 하면 UserRepository 객체는 Database 인터페이스에만 의존하게 되어 MySQL 데이터베이스를 사용하는 것이 아닌 PostgreSQL 데이터베이스를 사용하는 것으로 쉽게 변경할 수 있습니다.
public interface Database {
void save(User user);
}
public class MySQLDatabase implements Database {
@Override
public void save(User user) {
try (Connection connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/mydatabase", "username", "password")) {
String query = "INSERT INTO users (username, email) VALUES (?, ?)";
try (PreparedStatement preparedStatement = connection.prepareStatement(query)) {
preparedStatement.setString(1, user.getUsername());
preparedStatement.setString(2, user.getEmail());
preparedStatement.executeUpdate();
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}
public class PostgreSQLDatabase implements Database {
@Override
public void save(User user) {
try (Connection connection = DriverManager.getConnection("jdbc:postgresql://localhost:5432/mydatabase", "username", "password")) {
String query = "INSERT INTO users (username, email) VALUES (?, ?)";
try (PreparedStatement preparedStatement = connection.prepareStatement(query)) {
preparedStatement.setString(1, user.getUsername());
preparedStatement.setString(2, user.getEmail());
preparedStatement.executeUpdate();
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}
public class UserRepository {
private Database database;
public UserRepository(Database database) {
this.database = database;
}
public void save(User user) {
database.save(user);
}
}
[위는 의존성 역전 패턴을 활용하여 MySQL을 사용하다가 PostgreSQL을 사용하도록 변경한 코드입니다. UserRepository 객체는 Database 인터페이스에만 의존하고 있으며, Database 인터페이스를 구현한 MySQLDatabase와 PostgreSQLDatabase 클래스가 Database 인터페이스를 구현하고 있습니다.]
다양한 방식의 의존성 역전
여태까지는 의존성 역전을 구현하는 방법으로 상위 모듈이 하위 모듈을 추상화한 인터페이스나 추상 클래스에 의존하도록 하는 방법을 살펴봤습니다. 그러나 제가 이야기하는 것은 추상화한 인터페이스나 클래스 도입에 따른 결합도 감소 및 유연성 높이는 방법이 아닌, 의존성 역전 패턴입니다. 그 이유는 이제 다음에 이야기할 추가적인 두가지 형태로 의존성 역전을 구현하는 것이 가능하기 때문입니다.
의존성 주입
의존성 주입은 객체가 직접 자신이 사용할 객체를 생성하는 것이 아니라, 외부에서 객체를 주입받아 사용하는 방식을 말합니다. 이는 객체의 생성과 사용을 분리함으로써 객체의 재사용성을 높이고, 유연성을 높이는 장점이 있습니다. 의존성 주입은 다음과 같은 코드를 개선해야 하는 상황에서 사용할 수 있습니다.
class DataAnalyzer:
def __init__(self):
self.file_manager = BinaryFileManager()
def process_data(self, file_name):
self.file_manager.open_file(file_name)
self.analyze_data()
def analyze_data(self):
data = self.file_manager.read_file()
if data:
ones_count = self.count_ones(data)
print(f"Number of ones in the file: {ones_count}")
else:
print("No file open.")
def count_ones(self, data):
return data.count('1')
위의 코드에서 DataAnalyzer는 BinaryFileManager를 직접 생성하고 사용하고 있습니다. 이는 DataAnalyzer가 BinaryFileManager에 의존하고 있음을 의미합니다. 이를 의존성 주입을 통해 개선하면 다음과 같이 작성할 수 있습니다.
class DataAnalyzer:
def __init__(self, file_manager):
self.file_manager = file_manager
def process_data(self, file_name):
self.file_manager.open_file(file_name)
self.analyze_data()
def analyze_data(self):
data = self.file_manager.read_file()
if data:
ones_count = self.count_ones(data)
print(f"Number of ones in the file: {ones_count}")
else:
print("No file open.")
def count_ones(self, data):
return data.count('1')
의존성 주입을 통해 DataAnalyzer는 BinaryFileManager를 직접 생성하지 않고, 외부에서 주입받아 사용하고 있습니다. 이는 DataAnalyzer가 BinaryFileManager에 의존하지 않고, 외부에서 주입받은 객체에 의존하고 있음을 의미합니다.
그러나 의존성 주입은 객체의 생성과 사용을 분리함으로써 객체의 재사용성을 높이고, 유연성을 높이는 장점이 있지만, 객체를 생성하고 주입하는 코드가 복잡해질 수 있습니다.
이를 해결하기 위해 등장한 개념이 바로, 제어 역전입니다. 제어 역전은 객체의 생성과 사용을 분리함으로써 객체의 재사용성을 높이고, 유연성을 높이는 장점을 가지면서도, 객체를 생성하고 주입하는 코드가 복잡해지는 문제를 해결하기 위해 등장한 개념입니다.
제어 역전
제어 역전은 객체의 생성과 사용을 분리하기 위해 객체를 생성하고 사용하는 책임을 외부에 위임하는 것을 말합니다. 아까 전까지 이야기 한 의존성 주입과 얼핏 보면 동일한 이야기를 하는 것으로 이야기하기 쉽습니다. 하지만 의존성 주입은 객체를 생성하고 사용하는 책임을 외부에 위임하는 반면, 제어 역전은 객체를 생성하고 사용하는 책임을 객체 자신이 가지고 있는 것을 말합니다.
말이 좀 어렵죠? 집을 청소하는 상황을 비유를 들어서 한 번 이야기해보려 합니다. 집을 청소하는 상황에서, 집주인이 직접 청소를 하지 않고, 청소부에게 청소를 맡기는 것을 의존성 주입이라고 할 수 있습니다. 이 때 우리가 기존에 이야기 해 온 의존성 주입의 방식들은 집주인이 청소부에게 청소를 위임할 때, 필요한 도구들을 직접 전달하는 것과 같습니다.
그런데 이러기 위해서는 청소 도구들을 주인이 직접 관리해야할 뿐더러, 청소부가 어떤 도구를 사용해야 하는지에 대한 지시를 직접 해야하는 등의 문제가 있을 수 있습니다. 우리는 청소를 하기 싫어서 서비스를 이용하려는 것인데 오히려 더 많은 일을 해야하는 상황이 되는 것이죠.
그렇다면 아예 집안 청소 서비스를 이용한다면 어떻게 될까요? 우리는 단순히 서비스 업체를 이용하기만 하면 업체는 우리 집의 상황을 판단하고, 우리에게 필요한 모든 도구를 가져온 뒤에 청소를 해주는 것입니다. 이렇게 서비스를 이용하면 우리는 청소에 집중할 수 있고, 다른 것에 신경 쓰지 않아도 되는 것이죠.
그리고 이것이 바로 제어 역전입니다. 제어 역전은 우리가 맞이한 문제를 해결하기 위해 외부에 일을 맡기고, 그 일을 외부에서 해결하는 것을 말합니다. 마치 집안 청소 서비스를 이용하는 것처럼 말이죠.
비유를 통해 이야기해봤으니, 그러면 이제 코드를 통해 한 번 살펴보겠습니다. 예시로는 이제 우리에게 친숙해진 DataAnalyzer와 FileManager를 사용하겠습니다.
data_analyzer = DataAnalyzer(BinaryFileManager())
data_analyzer.process_data(sample_file_name)
만약 우리가 data_analyzer를 통해 BinaryFileManager를 사용하려 한다면, 위와 같은 코드를 사용하게 됩니다. 하지만 만약 기획이 바뀌어 우리에게 TextFileManager를 사용하라는 요구사항이 생긴다면, 우리는 다음과 같이 코드를 변경해야 할 것입니다.
data_analyzer = DataAnalyzer(TextFileManager())
이처럼 의존성 주입을 사용하고 있더라도, 기존의 방식으로는 여전히 객체를 생성하고 사용할 때 마다 문제 상황에 맞는 객체를 생성해야 하는 문제가 있습니다. 이런 현상을 이제 제어 역전을 사용하여 해결해보겠습니다.
class FileManagerController:
def __init__(self):
self.file_manager = None
def get_file_manager(self):
return self.file_manager
def set_file_manager(self, file_type):
if file_type == 'binary':
self.file_manager = BinaryFileManager()
elif file_type == 'text':
self.file_manager = TextFileManager()
file_manager_controller = FileManagerController()
file_manager_controller.set_file_manager(filename.split('.')[-1])
data_analyzer = DataAnalyzer(file_manager_controller.get_file_manager())
data_analyzer.process_data(sample_file_name)
이를 통해 DataAnalyzer는 들어온 파일을 처리할 때 더 이상 파일의 정보를 알 필요가 없어졌습니다. 이는 파일 매니저 컨트롤러가 파일의 정보를 알고 있기 때문입니다. 이처럼 제어 역전을 사용하면 객체를 생성하고 사용할 때 마다 문제 상황에 맞는 객체를 생성해야 하는 문제를 해결할 수 있습니다.
다만 분명 FileManagerController가 들어와서 제어가 역전됐는데, 얼핏 보기에는 코드가 복잡해지고 있는 것처럼 보일 수 있습니다. 그러면 더 나아가서, 만약 우리가 이 데이터 분석 모듈을 웹서버에 탑재해야하는 경우를 상정해보면 어떨까요?
class WebServer:
def __init__(self, data_analyzer):
self.data_analyzer = data_analyzer
def handle_request(self, file_name, file_type):
self.file_manager_controller.set_file_manager(file_type)
self.data_analyzer = DataAnalyzer(self.file_manager_controller.get_file_manager())
self.data_analyzer.process_data(file_name)
이제 우리는 보내오는 파일이 어떤 형태이든지간에, FileManagerController만이 이것을 처리할 뿐 이를 이용하는 WebServer는 어떤 파일이 들어오든지간에, FileManagerController에게 파일의 형태를 알려주기만 하면 됩니다. 다음처럼 말입니다. 사실 조금만 더 손 보면 파일 형태도 FileManagerController에게 알려주지 않아도 될 것입니다.
file_manager_controller = FileManagerController()
web_server = WebServer(file_manager_controller)
web_server.handle_request("data.bin", 'binary')
web_server.handle_request("data.txt", 'text')
마치며
긴 여정을 통해 의존성 역전에 대해 알아보았습니다. 의존성 역전은 객체지향 프로그래밍의 핵심 원리 중 하나로 객체 간의 결합도를 낮추고 유연성을 높이는 방법으로, 잘 사용하면 코드의 재사용성을 높이고 유지보수성을 높일 수 있습니다. 하지만 집안에 쓰레기가 얼마 없을 때는 빗자루를 집어드는 것이 청소 업체에 연락을 하는 것보다 우선하듯이, 의존성 역전 역시 코드의 유연성을 높일 필요한 상황에서 사용하는 것이 중요합니다.
여러분이 앞으로 프로그램을 개발하는 데 있어, 그리고 제가 개발하는 과정에 있어서, 마주하게 될 많은 문제 상황들 중에 한번 쯤 의존성 역전이 이를 천원돌파하는 최강의 드릴이 되길 기원합니다. 그리고 이 글이 여러분의 그 드릴을 조금이나마 빛나게 해줄 수 있었다면 저에게는 더할나위 없는 즐거움이 될 것입니다. 긴 글 읽어주셔서 감사합니다. 다음 주제는... 뭐 당장 생각해 둔 것은 없습니다만 다른 디자인 패턴 중 하나를 써보면 어떨까 싶네요.