지난 시간에는 객체 지향 프로그래밍의 네 가지 기둥, 추상화, 캡슐화, 상속, 다형성에 대해 배웠습니다.
이번 시간부터는 객체 지향 프로그래밍의 마지막 단계. 규모가 큰 코드의 복잡성을 줄이기 위해 객체 지향 코드를 견고하게 관리할 수 있는 방법에 대해 함께 알아보고자 합니다.
객체 지향 프로그래밍을 잘 한다는 기준이 뭘까요? 객체 지향 프로그래밍은 단순히 '객체를 만든다'에서 끝나는 것이 아니라 프로그램의 목적에 맞게 객체를 '어떻게' 디자인(설계)하느냐 고민하는 것이 매우 중요합니다. 그리고 이 과정을 도와주는 것이 바로 SOLID 원칙입니다.
SOLID 원칙은 Robert C. Martin이라는 유명 개발자가 2000년도에 처음 소개한 원칙으로, 사실상 객체 지향 프로그래밍의 표준 규칙처럼 알려져 있습니다.
객체 지향 프로그래밍에서의 SOLID는 각 원칙의 맨 앞 글자를 따서 만들어진 단어입니다.
- 단일 책임 원칙(Single Responsibility Principle)
- 개방 폐쇄 원칙(Open-Closed Principle)
- 리스코프 치환 원칙(Liskov Substitution Principle)
- 인터페이스 분리 원칙(Interface Segregation Principle)
- 의존 관계 역전 원칙(Dependency Inversion Principle)
하나 같이 생소하죠? 하지만 걱정 마세요. 지금부터 하나씩 알아갈 테니까요.
SOLID는 '견고한'이라는 뜻을 가지고 있죠? 그래서 SOLID의 모든 원칙을 지키면 유지보수가 쉬운 견고한 코드를 쓸 수 있다는 것을 나타내기도 합니다.
SOLID가 빛을 발휘하는 순간은 객체 지향 프로그래밍으로 만드는 프로그램의 크기가 클 때입니다. 이는 SOLID가 코드의 복잡성을 최소화하고 유지보수하기 쉬운 상태로 유지시키기 때문이죠.
반대로, 작고 간단한 프로그램을 만드는데 일일히 SOLID의 모든 원칙을 적용하려고 하면 비효율적입니다.
결국에는 SOLID 원칙을 따르는 코드와 위반하는 코드를 식별하고 각 원칙을 적용했을 때 얻는 이점을 아는 것이 중요합니다. 각 원칙이 유도하는 '견고'하고 '좋은' 프로그램의 모습을 눈에 익히는 거죠. 그럼 매 순간 모든 원칙을 완벽하게 적용하지는 못하더라도 코드에서 개선해야 할 점을 구분할 수는 있습니다.
SOLID 내용 특성상 긴 코드들이 자주 등장합니다. 따라서, 사전에 제공되는 코드의 내용을 미리 읽어보는 것이 좋을 것 같습니다.
시작하기 전, 아래 코드를 천천히 읽어보시길 바랍니다.
class Ship:
"""배 클래스"""
def __init__(self, fuel, fuel_per_hour, supplies, num_crew):
"""연료량, 시간당 연료 소비량, 물자량, 선원 수를 인스턴스 변수로 갖는다"""
self.fuel = fuel
self.fuel_per_hour = fuel_per_hour
self.supplies = supplies
self.num_crew = num_crew
def report_fuel(self):
"""연료량 보고 메소드"""
print("현재 연료는 {}l 남아 있습니다".format(self.fuel))
def load_fuel(self, amount):
"""연료 충전 메소드"""
self.fuel += amount
def report_supplies(self):
"""물자량 보고 메소드"""
print("현재 물자는 {}명분이 남아 있습니다".format(self.supplies))
def load_supplies(self, amount):
"""물자 보급 메소드"""
self.supplies += amount
def distribute_supplies_to_crew(self):
"""물자 배분 메소드"""
if self.supplies >= self.num_crew:
self.supplies -= self.num_crew
return True
print("물자가 부족하기 때문에 배분할 수 없습니다")
return False
def report_crew(self):
"""선원 수 보고 메소드"""
print("현재 선원 {}명이 있습니다".format(self.num_crew))
def load_crew(self, number):
"""선원 승선 메소드"""
self.num_crew += number
def run_engine_for_hours(self, hours):
"""엔진 작동 메소드"""
if self.fuel > self.fuel_per_hour * hours:
self.fuel -= self.fuel_per_hour * hours
print("엔진을 {}시간 동안 돌립니다!".format(hours))
else:
print("연료가 부족하기 때문에 엔진 작동을 시작할 수 없습니다")
단일 책임 원칙은 SOLID 원칙의 첫번째, S를 담당합니다. 이 원칙의 정의는 모든 클래스는 단 한 가지의 책임만을 가지고, 클래스 안에 정의되어 있는 모든 기능은 하나의 책임을 수행하는데 집중되어 있어야 한다는 것입니다. 쉽게 말해, 하나의 클래스로 너무 많은 일을 하지 말고 딱 한 가지 책임만 수행하라는 뜻을 가지고 있습니다.
그렇다면 한 가지 책임의 기준은 무엇일까요? 사실 이는 사람들마다 생각이 다르고 상황에 따라서도 다릅니다. SOLID 원칙의 창시자 Robert는 단일 책임 원칙에 대해 같이 수정해야될 것들은 묶고 따로 수정해야될 것들은 분리하는 것으로 그 기준을 정했습니다.
그가 이렇게 말한 이유는 프로그램의 유지보수와 관련이 있습니다. 프로그램에서 문제가 되는 코드가 여기저기 산발적으로 흩어져있다면 수정이 매우 어렵겠죠? 하지만 기능 변경을 할 코드들이 함께 모여 있다면 문제가 발생했을 때, 해당 코드만 수정하면 되니 매우 효율적입니다.
단일 책임 원칙에 따라 하나의 클래스가 하나의 책임만을 맡는다면 이후에 기능을 수정할 때 발생할 수 있는 문제를 막을 수 있습니다.
이제 위 코드를 분석해보도록 하죠. Ship 클래스의 인스턴스 변수에는 연료량, 시간당 연료 소비량, 물자량, 선원 수가 있습니다. 메소드로는 연료량 보고, 연료량 충전, 물자량 보고, 물자 보급, 물자 배분, 선원 수 보고, 선원 승선, 엔진 작동 메소드가 있습니다.
이제 이 Ship 클래스를 사용해보겠습니다. 인스턴스부터 생성해보죠.
ship = Ship(400, 10, 100, 50)
왼쪽부터 순서대로 연료량, 시간당 연료 소비량, 물자량, 선원 수입니다.
ship.load_fuel(10)
ship.load_supplies(10)
ship.load_crew(10)
연료와 물자, 선원을 각각 10리터, 10명 분, 10명 더 추가 해줬습니다.
ship.distribute_supplies_to_crew()
ship.run_engine_for_hours(4)
물자를 배분하고 4시간 동안 엔진을 작동시켰습니다.
ship.report_fuel()
ship.report_supplies()
ship.report_crew
마지막으로 배의 상태를 확인해봤습니다. 결과를 한 번 볼까요?
엔진을 4시간 동안 돌립니다!
현재 연료는 370l 남아 있습니다
현재 물자는 950명분이 남아 있습니다
현재 선원 60명이 있습니다
우선, 처음 연료량은 400l이었고 10l를 넣어 410l가 되었습니다. 이후, 엔진을 4시간 동안 동작하면서 한 시간마다 10l씩 빠져 410-10*4=370l
가 되었습니다.
다음으로 물자량은 1000개였는데 중간에 10개가 추가되어 1010, 선원수는 50명이었는데 중간에 10명이 추가되어 60이 되었습니다. 이후, 선원 60명 분의 물자를 배분하여 1010-60=950
이 남았습니다.
프로그램은 잘 동작되지만 문제가 하나 있습니다. Ship 클래스의 역할이 너무 많다는 것인데요. Ship 클래스 하나가 연료, 선원, 물자 등 너무 많은 책임을 지고 있네요.
이대로 Ship 클래스를 사용하면 변동 사항이 생길 때마다 내용을 수정해야 하는데 코드가 거대하다 보니 수정해야 할 부분을 찾기도 힘들도 수정할 것도 너무 많아집니다.
처음부터 책임 하나하나가 좀 더 작은 크기의 클래스에 나눠있었다면 바꾸고 싶은 내용이 있을 때 해당 부분만 수정하면 되므로 매우 편해질 것 같습니다.
Ship 클래스와 같이 여러 책임을 자신의 속성과 행동으로 직접 수행하는 객체를 GOD Object라고 합니다. 말 그대로 신처럼 모든 일을 수행한다고 해서 붙여진 이름입니다.
당장 필요한 것들을 하나에 모아 적으면 작성은 쉽겠지만 추후 변경 사항이 생길 때 수정하기가 매우 번거롭습니다. 좀 더 쉽게 코드를 수정하려면 클래스를 단일 책임 원칙에 맞게 분리해야 합니다.
현재 Ship 클래스가 가지고 있는 책임은 총 네 가지 책임입니다.
- 연료 관련 책임
- 선원 관련 책임
- 물자 관련 책임
- 엔진 관련 책임
이 각각의 책임을 맡은 클래스를 만들면 단일 책임 원칙을 지킬 수 있습니다. 이제 각 클래스를 만들고 기존 Ship 클래스는 이 네 가지 클래스의 인스턴스를 통해 원래 하던 기능을 수행하도록 만들어보겠습니다.
class Ship:
"""배 클래스"""
def __init__(self, fuel, fuel_per_hour, supplies, num_crew):
self.fuel_tank = FuelTank(fuel)
self.crew_manager = CrewManager(num_crew)
self.supply_hold = SupplyHold(supplies, self.crew_manager)
self.engine = Engine(self.fuel_tank, fuel_per_hour)
class FuelTank:
"""연료 탱크 클래스"""
def __init__(self, fuel):
"""연료 탱크에 저장된 연료량을 인스턴스 변수로 갖는다"""
self.fuel = fuel
def load_fuel(self, amount):
"""연료 충전 메소드"""
self.fuel += amount
def use_fuel(self, amount):
"""연료 사용 메소드"""
if self.fuel - amount >= 0:
self.fuel -= amount
return True
print("연료가 부족합니다!")
return False
def report_fuel(self):
"""연료량 보고 메소드"""
print("현재 연료는 {}l 남아 있습니다".format(self.fuel))
class Engine:
"""엔진 클래스"""
def __init__(self, fuel_tank, fuel_per_hour):
"""연료 탱크 인스턴스와 시간당 연료 소비량을 인스턴스 변수로 갖는다"""
self.fuel_tank = fuel_tank
self.fuel_per_hour = fuel_per_hour
def run_for_hours(self, hours):
"""엔진 작동 메소드, 연료 탱크 인스턴스를 사용한다"""
if self.fuel_tank.use_fuel(self.fuel_per_hour * hours):
print("엔진을 {}시간 동안 돌립니다!".format(hours))
return True
print("연료가 부족하기 때문에 엔진 작동을 시작할 수 없습니다")
return False
class CrewManager:
"""선원 관리 클래스"""
def __init__(self, num_crew):
"""승선한 선원 수를 인스턴스 변수로 갖는다"""
self.num_crew = num_crew
def load_crew(self, number):
"""선원 승선 메소드"""
self.num_crew += number
def report_crew(self):
"""선원 수 보고 메소드"""
print("현재 선원 {}명이 있습니다".format(self.num_crew))
class SupplyHold:
"""물자 창고 클래스"""
def __init__(self, supplies, crew_manager):
"""물자량과 선원 관리 인스턴스를 인스턴스 변수로 갖는다"""
self.supplies = supplies
self.crew_manager = crew_manager
def load_supplies(self, amount):
"""물자 충전 메소드"""
self.supplies += amount
def distribute_supplies_to_crew(self):
"""물자 배분 메소드, 각 선원들에게 동일한 양의 물자를 배분한다"""
if self.supplies >= self.crew_manager.num_crew:
self.supplies -= self.crew_manager.num_crew
return True
print("물자가 부족하기 때문에 배분할 수 없습니다")
return False
def report_supplies(self):
"""물자량 보고 메소드"""
print("현재 물자는 {}명분이 남아 있습니다".format(self.supplies))
위 코드를 보면 연료에 관련된 책임을 FuelTank가, 엔진과 관련된 책임을 Engine 클래스가, 선원에 관련된 책임은 CrewManager 클래스가, 그리고 물자에 관련된 책임은 SupplyHold 클래스가 맡도록 했습니다.
이제 Ship 클래스는 이 클래스들의 인스턴스를 통해 원래 하던 일을 합니다. 따라서, Ship 클래스 안에는 인스턴스 변수를 초기화하는 이닛 메소드만 있습니다. 이닛 메소드 내부를 보면 각 클래스들의 인스턴스들이 보입니다.
이들 중 어떤 인스턴스들은 다른 클래스의 인스턴스를 필요로 합니다. 예를 들어, 물자에 관한 책임을 담당할 SupplyHold 클래스의 인스턴스는 물자를 배분할 때 선원 수를 알아야 해서 CrewManager 클래스의 인스턴스 self.crew_manager
를 이닛 메소드의 파라미터로 받습니다.
정리하자면, 기존 Ship 클래스가 맡던 네 가지 책임을 새로운 네 가지 클래스에 각각 할당했습니다. 이와 같이 한 클래스가 여러 책임을 맡지 않고 각각 다른 클래스로 분할하는 것이 단일 책임 원칙을 지키는 방법입니다.
단일 책임 원칙을 적용해서 새로 만든 클래스들을 하나씩 보겠습니다.
class FuelTank:
"""연료 탱크 클래스"""
def __init__(self, fuel):
"""연료 탱크에 저장된 연료량을 인스턴스 변수로 갖는다"""
self.fuel = fuel
def load_fuel(self, amount):
"""연료 충전 메소드"""
self.fuel += amount
def use_fuel(self, amount):
"""연료 사용 메소드"""
if self.fuel - amount >= 0:
self.fuel -= amount
return True
print("연료가 부족합니다!")
return False
def report_fuel(self):
"""연료량 보고 메소드"""
print("현재 연료는 {}l 남아 있습니다".format(self.fuel))
연료 탱크를 나타내는 FuelTank 클래스에는 연료량을 나타내는 인스턴스 변수 fuel이 있습니다. 메소드로는 연료 충전을 위한 load_fuel, 연료량을 보고하는 report_fuel이 있습니다.
class Engine:
"""엔진 클래스"""
def __init__(self, fuel_tank, fuel_per_hour):
"""연료 탱크 인스턴스와 시간당 연료 소비량을 인스턴스 변수로 갖는다"""
self.fuel_tank = fuel_tank
self.fuel_per_hour = fuel_per_hour
def run_for_hours(self, hours):
"""엔진 작동 메소드, 연료 탱크 인스턴스를 사용한다"""
if self.fuel_tank.use_fuel(self.fuel_per_hour * hours):
print("엔진을 {}시간 동안 돌립니다!".format(hours))
return True
print("연료가 부족하기 때문에 엔진 작동을 시작할 수 없습니다")
return False
엔진을 나타내는 Engine 클래스에는 연료 탱크 인스턴스인 fuel_tank와 엔진의 시간당 연료 소비량인 fuel_per_hour가 있습니다. 메소드로는 엔진을 작동시키는 run_for_hours가 있습니다.
이닛 메소드 속 self.fuel_tank
는 Ship 클래스의 이닛 메소드 안에 있는 fuel_tank와 같은 인스턴스인데요. run_for_hours 메소드는 연료를 사용하기 위해 fuel_tank 인스턴스의 use_fuel 메소드를 호출합니다.
class CrewManager:
"""선원 관리 클래스"""
def __init__(self, num_crew):
"""승선한 선원 수를 인스턴스 변수로 갖는다"""
self.num_crew = num_crew
def load_crew(self, number):
"""선원 승선 메소드"""
self.num_crew += number
def report_crew(self):
"""선원 수 보고 메소드"""
print("현재 선원 {}명이 있습니다".format(self.num_crew))
선원을 관리하는 CrewManager 클래스입니다. 이 클래스는 배에 탄 선원의 수를 나타내는 인스턴스 변수 num_crew를 가지고 있고 메소드로는 배에 선원을 태우는 load_crew, 배에 탄 선원의 수를 알려주는 report_crew가 있습니다.
class SupplyHold:
"""물자 창고 클래스"""
def __init__(self, supplies, crew_manager):
"""물자량과 선원 관리 인스턴스를 인스턴스 변수로 갖는다"""
self.supplies = supplies
self.crew_manager = crew_manager
def load_supplies(self, amount):
"""물자 충전 메소드"""
self.supplies += amount
def distribute_supplies_to_crew(self):
"""물자 배분 메소드, 각 선원들에게 동일한 양의 물자를 배분한다"""
if self.supplies >= self.crew_manager.num_crew:
self.supplies -= self.crew_manager.num_crew
return True
print("물자가 부족하기 때문에 배분할 수 없습니다")
return False
def report_supplies(self):
"""물자량 보고 메소드"""
print("현재 물자는 {}명분이 남아 있습니다".format(self.supplies))
마지막으로 물자 창고를 나타내는 SupplyHold 클래스가 있습니다. 이 클래스에는 물자량을 나타내는 supplies 변수와 CrewManager 클래스의 인스턴스가 하나 있습니다. 메소드로는 물자를 충전하는 load_supplies, 물자를 배분하는 distribute_supplies_to_crew, 남은 물자량을 알려주는 report_supplies가 있습니다.
distribute_supplies_to_crew 메소드에서는 배에 탄 선원 수를 알아야 하는데요. 그래야 선원 수만큼 물자를 나눠줄 수 있으니까요. 선원 수는 CrewManager 클래스의 인스턴스 변수 num_crew로 알 수 있습니다.
그럼 이제 Ship 클래스를 사용해보겠습니다. 인스턴스 생성은 앞서 활용했던 코드를 그대로 사용하면 됩니다.
ship.fuel.tank.load_fuel(10)
ship.supply_hold.load_supplies(10)
ship.crew_manager.load_crew(10)
선원 승선, 물자 및 연료 재보급을 진행했습니다. 아까 작성했던 코드와 다른 점이 있다면 역할에 맞는 클래스로부터 메소드를 호출했다는 것이네요.
ship.supply_hold.distribute_supplies_to_crew()
ship.engine.run_for_hours(4)
물자 배분과 엔진 작동을 진행했습니다. 마찬가지로 각각의 클래스로부터 메소드를 호출했습니다.
ship.fuel_tank.report_fuel()
ship.supply_hold.report_supplies()
ship.crew_manager.report_crew()
선박 상태 보고까지 마쳤습니다. 코드를 실행해보면 앞서 Ship 클래스의 코드와 동일한 결과가 나옵니다.
이처럼 단일 책임 원칙을 적용한 후에는 Ship 클래스의 인스턴스가 어떤 동작을 자신의 메소드로 직접하지 않습니다. 그 대신 관련된 책임을 담당한 다른 클래스의 인스턴스를 통해 그 동작을 하게 됩니다.
단일 책임 원칙을 적용하기 전 Ship 클래스의 코드 길이는 50입니다. 적용 후, 전체 코드 길이는 80인데요. 이렇게만 보면 적용 후의 코드가 더 길기때문에 더 비효율적이라 생각하실 수 있습니다. 하지만 분리된 클래스당 평균 코드 길이를 보면 20으로 적용 전 코드보다 짧습니다.
단일 책임 원칙을 적용하여 전체 코드 길이가 길어졌다 하더라도 하나의 클래스를 사용하는 것보다 여러 개의 클래스를 사용하는 것이 더 효율적입니다. 그래야 각 클래스의 의미를 파악하기도 쉽고 유지보수에 용이하기 때문이죠.
사실 어떤 클래스를 보고 단일 책임 원칙을 지켰는지 판단하는 것은 쉽지 않습니다. 어떤 프로그램을 개발하느냐에 따라 개발자의 생각이 제각기 다르기 때문이죠. Ship 클래스도 다른 누군가는 또 다른 방식으로 단일 책임 원칙을 적용할 수 있습니다. 따라서, 단일 책임 원칙에 답은 없습니다.
그러나 중요한 것은 코드를 작성할 때, 단일 책임 원칙을 지켰는지 생각해보는 것입니다. 하나의 클래스가 너무 많은 책임을 가지진 않았는지, 분리할 수 있는 변수와 메소드가 많은 것은 아닌지를 항상 고민해 봐야 합니다.
특히, 코드 내용이 많아 수정이 어려워졌다고 생각하면 단일 책임 원칙을 지키지 않았다는 것이 명확해집니다. 추후 유지보수를 위해서라도 꼭 단일 책임 원칙을 기억하며 코드를 작성하도록 노력해봅시다.
정리하자면, 단일 책임 원칙의 기준인 한 가지 책임이라는 것은 사람들마다 생각하는 것이 다르고 상황에 따라서도 달라질 수 있습니다. 그나마 SOLID의 개발자 Robert의 정의에 따라 유지보수에 용이한 코드로 분리하는 것을 대표적인 기준이라고 볼 수 있겠네요.
이 기준에서 보면 한 클래스는 한 가지 책임에 관한 변경사항이 생겼을 때만 코드를 수정하게 되는 구조가 좋은 구조라고 볼 수 있습니다.
그러니 코드를 작성할 때, 단일 책임 원칙에 대해 늘 생각하면서 추후 유지보수에 용이하도록 책임이 분할 된 코드 작성의 필요성을 염두에 두어야 합니다.
이번 시간에는 견고한 객체 지향 프로그래밍을 할 수 있는 다섯가지 원칙 SOLID의 개념과 그 첫번째 원칙 단일 책임 원칙에 대해 알아봤습니다. '한 가지 클래스에는 한 가지 책임만!'을 꼭 기억하시길 바랍니다.
다음 시간에는 두번째 원칙인 개방 폐쇄의 원칙에 대해 알아보겠습니다.
* 이 강의는 CODEIT의 '객체 지향 프로그래밍' 강의를 기반으로 작성되었습니다.