개인적으로 진행하고 있는 DSA(Distributed Spacecraft Autonomy Testbed) 프로젝트를 진행하고 있다. 우주선이 임무를 수행할지 말지 결정하고, 어떤 우주선이 이 임무를 수행할 것인가? 를 결정하는 로직을 짜고 있는데 이 과정에서 SoC를 적용해보았다.
SoC(Separation of Concerns, 관심사의 분리)는 하나의 함수나 모듈이 하나의 관심사만 가지도록 나누는 설계 원칙이다.
여기서 "관심사"는 프로그램이 신경 써야 하는 문제 하나하나를 의미한다. 예를 들어 "이 작업이 수행 가능한가?"와 "이 작업의 우선순위 점수는 몇 점인가?"는 서로 다른 질문이고, 서로 다른 관심사다.
관심사를 나누지 않으면 이런 함수가 나올 수 있다.
def evaluate(spacecraft, task):
if not spacecraft.sensor_availability:
return -1 # 불가능
if spacecraft.battery < task.required_energy:
return -1 # 불가능
score = task.priority - task.required_energy
return score
이 함수는 "가능/불가능 판단"과 "점수 계산"을 동시에 하고 있다.
센서 상태가 사용 가능한 상태인지, 임무 수행에 필요한 에너지가 충분한지는 임무의 우선순위를 결정하기 이전에 임무 수행이 가능한지를 우선적으로 판단하는 조건이다. 이렇게 관심사가 나눠지지 않은 상태라면 조건이 추가될 때마다 점수 계산 로직 옆에 끼워 넣어야 한다.
그리고 점수 계산 공식만 따로 테스트하고 싶어도, 불가능 케이스(-1)까지 같이 처리해줘야 해서 테스트 케이스가 지저분해질 여지가 있다.
관심사를 나누면 각 함수는 자기 역할만 신경 쓰면 되고, 한쪽을 바꿔도 다른 쪽 로직을 건드릴 필요가 없다.
이 프로젝트에서는 각 spacecraft의 상태, 그리고 주어진 임무의 상태를 보고 "어떤 spacecraft가 이 임무를 맡게 될 것인가?"를 결정한다.
그렇게 되려면 각 spacecraft는 다음 순서로 판단해야한다.
1. 이 임무가 현재 상태에서 수행가능한가?(is_feasible 함수)
2. 이 임무를 내가 수행하게 된다면 얼마만큼의 가치를 가지는가?(bid 함수, 클수록 우선순위가 큼)
def is_feasible(spacecraft: Spacecraft, task: Task, current_time: float) -> bool:
if not spacecraft.sensor_availability:
return False
if spacecraft.health_status == HealthStatus.FAILED:
return False
if spacecraft.storage < task.required_storage:
return False
if current_time + task.observation_duration > task.deadline:
return False
if task.id in spacecraft.current_tasks:
return False
if _battery_margin(spacecraft, task) < SAFE_SOC_RESERVE:
return False
return True
def bid(spacecraft: Spacecraft, task: Task, link: Link | None = None,
weights: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0)) -> float:
w1, w2, w3, w4 = weights
return (
w1 * task.priority
- w2 * _energy_cost(spacecraft, task)
- w3 * _slew_cost_proxy(spacecraft, task)
+ w4 * _battery_margin(spacecraft, task)
)
is_feasible()은 "이 우주선이 이 작업을 할 수 있는가?" (bool)를 판단한다. 여기에서 작업 불가능한 경우를 미리 거른 후, 작업 가능하다고 판단된 spacecraft에 대해서만 bid 함수로 가치를 계산한다.
bid()는 가능하다는 전제 하에 "이 작업을 얼마나 하고 싶은가?"를 계산한다 (숫자 점수)
이렇게 나눠두면 두 가지가 좋아지는데,
is_feasible()만 따로 테스트할 수 있다. (배터리 부족일 때, 센서 고장일 때, 마감 못 맞출 때 등 각 조건을 독립적으로 검증)bid()의 점수 공식은 건드릴 필요가 없다.bid() 내부의 각 항목(_energy_cost, _battery_margin, _slew_cost_proxy)도 헬퍼 함수로 분리해뒀다.
def _energy_cost(spacecraft: Spacecraft, task: Task) -> float:
return task.required_energy
def _battery_margin(spacecraft: Spacecraft, task: Task) -> float:
return spacecraft.battery - task.required_energy
def _slew_cost_proxy(spacecraft: Spacecraft, task: Task) -> float:
raw = _distance(spacecraft.position, task.target_location)
return min(raw, SLEW_PROXY_DISTANCE_SCALE)
bid()는 이 조각들을 조합만 하고, 각 계산식이 어떻게 구현되는지는 신경 쓰지 않는다. 나중에 슬루 비용 계산 방식을 바꾸고 싶으면 _slew_cost_proxy() 하나만 고치면 되고, bid()나 is_feasible()은 그대로 둬도 된다.