Java/Java 자료실

[SOLID] 단일책임원칙(SRP)과 God Object(신 객체)

Chipmunks 2023. 11. 22.
728x90

 

안녕하세요. 단일 책임 원칙과 신 객체가 무엇인지 알아보려고 합니다.

 

단일 책임 원칙 - 책임이란 뭘까?

 

단일 책임 원칙은 SOLID 원칙 중 S 에 해당합니다.

Single Responsibility Principle (SRP) 이라고 불립니다.

아래는 위키 백과의 설명 중 일부입니다.

모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화해야 함을 일컫는다.
클래스가 제공하는 모든 기능은 이 책임과 주의 깊게 부합해야 한다.

...

로버트 마틴은 책임을 변경하려는 이유로 정의하고, 어떤 클래스나 모듈은 변경하려는 단 하나 이유만을 가져야 한다고 결론 짓는다.
예를 들어서 보고서를 편집하고 출력하는 모듈을 생각해 보자.
이 모듈은 두 가지 이유로 변경될 수 있다.
첫 번째로 보고서의 내용 때문에 변경될 수 있다.
두 번째로 보고서의 형식 때문에 변경될 수 있다.
이 두 가지 변경은 하나는 실질적이고 다른 하나는 꾸미기 위한 매우 다른 원인에 기인한다.
단일 책임 원칙에 의하면 이 문제의 두 측면이 실제로 분리된 두 책임 때문이며,
따라서 분리된 클래스나 모듈로 나누어야 한다.
다른 시기에 다른 이유로 변경되어야 하는 두 가지를 묶는 것은 나쁜 설계일 수 있다.

 

단일 책임 원칙에서의 책임은 변경하려는 이유로 정의합니다.

같이 변경되어야 할 것들을 모아야 합니다.

다른 이유로 수정되어야할 책임은 분리된 클래스나 모듈로 나누어야 합니다.

 

많은 책임이 있는 클래스 - God Object (신 객체)

다음 코드 예제는 다음 블로그의 코드를 자바로 옮겼습니다.

 

바다 위 배를 Ship 클래스로 만들고자 한다.

Ship 클래스는 다음과 같은 멤버 변수를 가집니다.

  • fuelAmount : 남아있는 연료량
  • fuelConsumptionAmountPerHour : 시간당 소비하는 연료량
  • suppliesAmount : 물자량
  • crewCount : 선원들의 수

그리고 다음과 같은 메소드를 가집니다.

  • 연료 메소드
    • reportFuel : 남아있는 연료량을 알려줌
    • loadFuel : 연료를 충전
  • 물자 메소드
    • reportSupplies : 물자를 알려줌
    • loadSupplies : 물자를 보급
    • allocateSuppliesToCrews : 선원들에게 물자를 보급
  • 선원 메소드
    • reportCrew : 선원의 수를 알려줌
    • loadCrew : 새로운 선원을 태움
  •  엔진 메소드
    • runEngineForHours : 엔진을 시간만큼 작동시킴

 

이를 자바 코드로 구현하면 아래와 같습니다.

( Java SDK 17 버전 기준 )

 

package domain.ship;

public final class Ship {
    private int fuelAmount;
    private final int fuelConsumptionAmountPerHour;
    private int suppliesAmount;
    private int crewCount;

    public Ship(
            final int fuelAmount,
            final int fuelConsumptionAmountPerHour,
            final int suppliesAmount,
            final int crewCount
    ) {
        validateFuelAmount(fuelAmount);
        validateFuelConsumptionAmountPerHour(fuelConsumptionAmountPerHour);
        validateSuppliesAmount(suppliesAmount);
        validateCrewCount(crewCount);

        this.fuelAmount = fuelAmount;
        this.fuelConsumptionAmountPerHour = fuelConsumptionAmountPerHour;
        this.suppliesAmount = suppliesAmount;
        this.crewCount = crewCount;
    }

    private void validateFuelAmount(final int fuelAmount) {
        if (fuelAmount < 0) {
            throw new IllegalArgumentException("남아있는 연료량은 음수가 될 수 없습니다.");
        }
    }

    private void validateFuelConsumptionAmountPerHour(final int fuelConsumptionAmountPerHour) {
        if (fuelConsumptionAmountPerHour < 0) {
            throw new IllegalArgumentException("시간당 소비하는 연료량은 음수가 될 수 없습니다.");
        }
    }

    private void validateSuppliesAmount(final int suppliesAmount) {
        if (suppliesAmount < 0) {
            throw new IllegalArgumentException("물자량은 음수가 될 수 없습니다.");
        }
    }

    private void validateCrewCount(final int crewCount) {
        if (crewCount < 0) {
            throw new IllegalArgumentException("선원들의 수는 음수가 될 수 없습니다.");
        }
    }

    public void reportFuel() {
        System.out.println("현재 연료는 %dL 남아 있습니다.".formatted(fuelAmount));
    }

    public void loadFuel(final int loadFuelAmount) {
        fuelAmount += loadFuelAmount;
    }

    public void reportSupplies() {
        System.out.println("현재 물자는 %d명 분이 남아 있습니다.".formatted(suppliesAmount));
    }

    public void loadSupplies(final int loadSuppliesAmount) {
        suppliesAmount += loadSuppliesAmount;
    }

    public void allocateSuppliesToCrew() {
        if (isLackOfSuppliesAmount()) {
            System.out.println("물자가 부족하기 때문에 배분할 수 없습니다.");
            return;
        }

        suppliesAmount -= crewCount;
    }

    private boolean isLackOfSuppliesAmount() {
        return suppliesAmount < crewCount;
    }

    public void reportCrew() {
        System.out.println("현재 선원 %d명이 있습니다.".formatted(crewCount));
    }

    public void loadCrew(final int loadCrewCount) {
        crewCount += loadCrewCount;
    }

    public void runEngineForHours(final int hours) {
        validateHours(hours);

        final int consumeFuelAmount = fuelConsumptionAmountPerHour * hours;
        if (!isAvailableRunningEngine(consumeFuelAmount)) {
            System.out.println("연료가 부족하기 때문에 엔진 작동을 시작할 수 없습니다.");
            return;
        }

        fuelAmount -= consumeFuelAmount;
        System.out.println("엔진을 %d시간 동안 돌립니다!".formatted(hours));
    }

    private void validateHours(final int hours) {
        if (hours < 0) {
            throw new IllegalArgumentException("시간은 음수가 될 수 없습니다.");
        }
    }

    private boolean isAvailableRunningEngine(final int consumeFuelAmount) {
        return fuelAmount >= consumeFuelAmount;
    }
}

 

Ship 클래스를 다음과 같이 테스트 했습니다.

 

초기 Ship 객체는 다음과 같은 데이터가 있습니다.

  • 연료는 400 L
  • 시간 당 연료 소비량은 10 L
  • 물자량은 1,000 명 분
  • 선원은 50 명
final Ship ship = new Ship(
        400,
        10,
        1_000,
        50
);

 

연료 10 L, 물자 10 명 분, 선원 10 명을 추가합니다.

 

ship.loadFuel(10);
ship.loadSupplies(10);
ship.loadCrew(10);

 

물자 배분을 합니다.

 

ship.allocateSuppliesToCrew();

 

엔진을 4 시간 동안 작동합니다.

 

ship.runEngineForHours(4);

 

배의 상태를 확인하는 메소드를 호출합니다.

 

ship.reportFuel();
ship.reportSupplies();
ship.reportCrew();

 

배의 상태는 아래와 같이 출력됩니다.

엔진을 4시간 동안 돌립니다!
현재 연료는 370L 남아 있습니다.
현재 물자는 950명 분이 남아 있습니다.
현재 선원 60명이 있습니다.

 

테스트 코드 전문은 아래와 같습니다.

package domain.ship;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;

import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class ShipTest {

    private PrintStream standardOut;
    private OutputStream captor;

    @BeforeEach
    void init() {
        standardOut = System.out;
        captor = new ByteArrayOutputStream();
        System.setOut(new PrintStream(captor));
    }

    @AfterEach
    void printOutput() {
        System.setOut(standardOut);
        System.out.println(output());
    }

    String output() {
        return captor.toString();
    }

    @Test
    void integrateTest() {
        final Ship ship = new Ship(
                400,
                10,
                1_000,
                50
        );

        ship.loadFuel(10);
        ship.loadSupplies(10);
        ship.loadCrew(10);

        ship.allocateSuppliesToCrew();

        ship.runEngineForHours(4);

        ship.reportFuel();
        ship.reportSupplies();
        ship.reportCrew();

        assertThat(output()).contains(
                "엔진을 4시간 동안 돌립니다!",
                "현재 연료는 370L 남아 있습니다.",
                "현재 물자는 950명 분이 남아 있습니다.",
                "현재 선원 60명이 있습니다."
        );
    }
}

 

God Object

위 예시에서 Ship 클래스를 변경하려고 합니다.

이 때 변경하고자 하는 이유, 즉 책임은 어떤 것들이 있을까요?

 

  1. 연료 코드를 변경하는 이유가 될 수 있습니다.
  2. 물자량 코드를 변경하는 이유가 될 수 있습니다.
  3. 선원 코드를 변경하는 이유가 될 수 있습니다.
  4. 엔진 코드를 변경하는 이유가 될 수 있습니다.

위처럼 하나의 클래스에 여러 개의 책임이 있는 클래스를 신 객체, God Object, 라고 합니다.

하나의 클래스가 많은 일을 한다는 모습을 비유적으로 표현했습니다.

그렇다면 위 책임을 어떻게 분리해볼 수 있을까요?

 

책임을 분리하는 방법

 

단일 책임 원칙에 따르면 책임은 각각 분리된 클래스로 만들어야 한다고 주장합니다.

연료, 물자량, 선원, 엔진의 책임이 있으므로 각각 독립된 클래스로 만들어 봅니다.

또한 더 이상 어떤 역할의 메소드인지 메소드 명에 기입할 필요가 없게 됩니다.

( loadFuel -> load )

 

연료 FuelTank 클래스

남아있는 연료와 관련한 로직을 FuelTank 클래스에 위치시킵니다.

 

package domain.ship;

public class FuelTank {
    private int fuelAmount;

    public FuelTank(final int fuelAmount) {
    	validateFuelAmount(fuelAmount);
    
        this.fuelAmount = fuelAmount;
    }

    private void validateFuelAmount(final int fuelAmount) {
        if (fuelAmount < 0) {
            throw new IllegalArgumentException("남아있는 연료량은 음수가 될 수 없습니다.");
        }
    }

    public void report() {
        System.out.println("현재 연료는 %dL 남아 있습니다.".formatted(fuelAmount));
    }

    public void load(final int loadFuelAmount) {
        fuelAmount += loadFuelAmount;
    }
}

 

물자량 SupplyHold 클래스

물자량과 관련한 로직을 SupplyHold 클래스에 위치시킵니다.

 

package domain.ship;

public class SupplyHold {
    private int suppliesAmount;

    public SupplyHold(final int suppliesAmount) {
        validateSuppliesAmount(suppliesAmount);

        this.suppliesAmount = suppliesAmount;
    }

    private void validateSuppliesAmount(final int suppliesAmount) {
        if (suppliesAmount < 0) {
            throw new IllegalArgumentException("물자량은 음수가 될 수 없습니다.");
        }
    }

    public void report() {
        System.out.println("현재 물자는 %d명 분이 남아 있습니다.".formatted(suppliesAmount));
    }

    public void load(final int loadSuppliesAmount) {
        suppliesAmount += loadSuppliesAmount;
    }
    
    public void allocateToCrew() {
    if (isLackOfSuppliesAmount()) {
        System.out.println("물자가 부족하기 때문에 배분할 수 없습니다.");
        return;
    }

        suppliesAmount -= crewCount;
    }

    private boolean isLackOfSuppliesAmount() {
        return suppliesAmount < crewCount;
    }
}

 

선원 수 CrewManager 클래스

배에 있는 선원 수를 관리하는 로직을 CrewManager 클래스로 위치시킵니다.

 

package domain.ship;

public class CrewManager {
    private int crewCount;

    public CrewManager(final int crewCount) {
        validateCrewCount(crewCount);

        this.crewCount = crewCount;
    }

    private void validateCrewCount(final int crewCount) {
        if (crewCount < 0) {
            throw new IllegalArgumentException("선원들의 수는 음수가 될 수 없습니다.");
        }
    }

    public void report() {
        System.out.println("현재 선원 %d명이 있습니다.".formatted(crewCount));
    }

    public void load(final int loadCrewCount) {
        crewCount += loadCrewCount;
    }
}

 

엔진 Engine 클래스

엔진과 관련한 로직을 Engine 클래스로 위치시킵니다.

package domain.ship;

public class Engine {
    private final int fuelConsumptionAmountPerHour;

    public Engine(final int fuelConsumptionAmountPerHour) {
        validateFuelConsumptionAmountPerHour(fuelConsumptionAmountPerHour);

        this.fuelConsumptionAmountPerHour = fuelConsumptionAmountPerHour;
    }

    private void validateFuelConsumptionAmountPerHour(final int fuelConsumptionAmountPerHour) {
        if (fuelConsumptionAmountPerHour < 0) {
            throw new IllegalArgumentException("시간당 소비하는 연료량은 음수가 될 수 없습니다.");
        }
    }

    public void runForHours(final int hours) {
        validateHours(hours);

        final int consumeFuelAmount = fuelConsumptionAmountPerHour * hours;

        if (!isAvailableRunning(consumeFuelAmount)) {
            System.out.println("연료가 부족하기 때문에 엔진 작동을 시작할 수 없습니다.");
            return;
        }

        fuelTank.consume(consumeFuelAmount);
        System.out.println("엔진을 %d시간 동안 돌립니다!".formatted(hours));
    }
    
    private void validateHours(final int hours) {
        if (hours < 0) {
            throw new IllegalArgumentException("시간은 음수가 될 수 없습니다.");
        }
    }

    private boolean isAvailableRunning(final int consumeFuelAmount) {
        return fuelTank.getFuelAmount() >= consumeFuelAmount;
    }
}

 

클래스를 분리하고 나서 생기는 이상한 점

위 코드에서 이상한 점을 발견하지 못하셨나요?

아마 실제로 코드로 만들어보신 분들은 컴파일 오류가 나타나는 걸 확인할 수 있습니다.

바로 Engine 클래스에서 fuelAmount 를 찾을 수 없습니다.

 

fuelAmount -= consumeFuelAmount;
...
return fuelAmount >= consumeFuelAmount;

 

 

Engine 객체는 FuelTank 객체와 소통해야 합니다.

Engine 객체를 생성할 때 FuelTank 를 생성자로 전달합니다.

FuelTank 객체에게 연료량이 줄어들도록 메시지를 보내야 합니다.

 

package domain.ship;

public class Engine {
    private final FuelTank fuelTank;
    private final int fuelConsumptionAmountPerHour;

    public Engine(
            final FuelTank fuelTank,
            final int fuelConsumptionAmountPerHour
    ) {
        validateFuelTank(fuelTank);
        validateFuelConsumptionAmountPerHour(fuelConsumptionAmountPerHour);

        this.fuelTank = fuelTank;
        this.fuelConsumptionAmountPerHour = fuelConsumptionAmountPerHour;
    }

    private void validateFuelConsumptionAmountPerHour(final int fuelConsumptionAmountPerHour) {
        if (fuelConsumptionAmountPerHour < 0) {
            throw new IllegalArgumentException("시간당 소비하는 연료량은 음수가 될 수 없습니다.");
        }
    }

    private void validateFuelTank(final FuelTank fuelTank) {
        if (fuelTank == null) {
            throw new IllegalArgumentException("연료 창고는 null 이 될 수 없습니다.");
        }
    }

    public void runForHours(final int hours) {
        validateHours(hours);

        final int consumeFuelAmount = fuelConsumptionAmountPerHour * hours;

        if (!isAvailableRunning(consumeFuelAmount)) {
            System.out.println("연료가 부족하기 때문에 엔진 작동을 시작할 수 없습니다.");
            return;
        }

        fuelTank.consume(consumeFuelAmount);
        System.out.println("엔진을 %d시간 동안 돌립니다!".formatted(hours));
    }

    private void validateHours(final int hours) {
        if (hours < 0) {
            throw new IllegalArgumentException("시간은 음수가 될 수 없습니다.");
        }
    }

    private boolean isAvailableRunning(final int consumeFuelAmount) {
        return fuelTank.getFuelAmount() >= consumeFuelAmount;
    }
}

 

 

package domain.ship;

public class FuelTank {
    private int fuelAmount;

    public FuelTank(final int fuelAmount) {
        validateFuelAmount(fuelAmount);
        
        this.fuelAmount = fuelAmount;
    }

    private void validateFuelAmount(final int fuelAmount) {
        if (fuelAmount < 0) {
            throw new IllegalArgumentException("남아있는 연료량은 음수가 될 수 없습니다.");
        }
    }

    public void report() {
        System.out.println("현재 연료는 %dL 남아 있습니다.".formatted(fuelAmount));
    }

    public void load(final int loadFuelAmount) {
        fuelAmount += loadFuelAmount;
    }

    public void consume(final int consumedFuelAmount) {
        fuelAmount -= consumedFuelAmount;
    }

    public int getFuelAmount() {
        return fuelAmount;
    }
}

 

마찬가지로 SupplyHold 도 물자 배급을 위해 CrewManager 객체와 소통해야 합니다.

 

package domain.ship;

public class SupplyHold {
    private final CrewManager crewManager;
    private int suppliesAmount;

    public SupplyHold(
            final CrewManager crewManager,
            final int suppliesAmount
    ) {
        validateCrewManager(crewManager);
        validateSuppliesAmount(suppliesAmount);

        this.crewManager = crewManager;
        this.suppliesAmount = suppliesAmount;
    }

    private void validateCrewManager(final CrewManager crewManager) {
        if (crewManager == null) {
            throw new IllegalArgumentException("CrewManager 객체는 null 이 될 수 없습니다.");
        }
    }

    private void validateSuppliesAmount(final int suppliesAmount) {
        if (suppliesAmount < 0) {
            throw new IllegalArgumentException("물자량은 음수가 될 수 없습니다.");
        }
    }

    public void report() {
        System.out.println("현재 물자는 %d명 분이 남아 있습니다.".formatted(suppliesAmount));
    }

    public void load(final int loadSuppliesAmount) {
        suppliesAmount += loadSuppliesAmount;
    }

    public void allocateToCrew() {
        if (isLackOfSuppliesAmount()) {
            System.out.println("물자가 부족하기 때문에 배분할 수 없습니다.");
            return;
        }

        suppliesAmount -= crewManager.getCrewCount();
    }

    private boolean isLackOfSuppliesAmount() {
        return suppliesAmount < crewManager.getCrewCount();
    }
}

 

package domain.ship;

public class CrewManager {
    private int crewCount;

    public CrewManager(final int crewCount) {
        validateCrewCount(crewCount);

        this.crewCount = crewCount;
    }

    private void validateCrewCount(final int crewCount) {
        if (crewCount < 0) {
            throw new IllegalArgumentException("선원들의 수는 음수가 될 수 없습니다.");
        }
    }

    public void report() {
        System.out.println("현재 선원 %d명이 있습니다.".formatted(crewCount));
    }

    public void load(final int loadCrewCount) {
        crewCount += loadCrewCount;
    }

    public int getCrewCount() {
        return crewCount;
    }
}

Ship 클래스의 책임은 무엇일까?

Ship 클래스는 배의 구성 부품인 FuelTank, SupplyHold, CrewManager, Engine 를 관리하는 책임을 가집니다.

외부의 사용자 요청을 내부 구성 부품과 함께 처리하는 역할을 합니다.

예시 코드에선 FuelTank, SupplyHold, CrewManager 의 상태를 모두 출력해주는 메소드를 제공하고 있습니다.

 

구성 부품 객체끼리의 통신을 Ship 클래스에서 맡아도 되지만

이러한 의존성은 단일 책임 원칙 설명과는 벗어나는 주제라 넘어가겠습니다.

 

package domain.ship;

public final class Ship {
    private final FuelTank fuelTank;
    private final SupplyHold supplyHold;
    private final CrewManager crewManager;
    private final Engine engine;

    public Ship(
            final FuelTank fuelTank,
            final SupplyHold supplyHold,
            final CrewManager crewManager,
            final Engine engine
    ) {
        validateFuelTank(fuelTank);
        validateSupplyHold(supplyHold);
        validateCrewManager(crewManager);
        validateEngine(engine);

        this.fuelTank = fuelTank;
        this.supplyHold = supplyHold;
        this.crewManager = crewManager;
        this.engine = engine;
    }

    private void validateFuelTank(final FuelTank fuelTank) {
        if (fuelTank == null) {
            throw new IllegalArgumentException("FuelTank 객체는 null 이 될 수 없습니다.");
        }
    }

    private void validateSupplyHold(final SupplyHold supplyHold) {
        if (supplyHold == null) {
            throw new IllegalArgumentException("SupplyHold 객체는 null 이 될 수 없습니다.");
        }
    }

    private void validateCrewManager(final CrewManager crewManager) {
        if (crewManager == null) {
            throw new IllegalArgumentException("CrewManager 객체는 null 이 될 수 없습니다.");
        }
    }

    private void validateEngine(final Engine engine) {
        if (engine == null) {
            throw new IllegalArgumentException("Engine 객체는 null 이 될 수 없습니다.");
        }
    }

    public void report() {
        fuelTank.report();
        supplyHold.report();
        crewManager.report();
    }
}

 

테스트 코드도 아래처럼 변경이 가능합니다.

 

package domain.ship;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;

import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class ShipTest {

    private PrintStream standardOut;
    private OutputStream captor;

    @BeforeEach
    void init() {
        standardOut = System.out;
        captor = new ByteArrayOutputStream();
        System.setOut(new PrintStream(captor));
    }

    @AfterEach
    void printOutput() {
        System.setOut(standardOut);
        System.out.println(output());
    }

    String output() {
        return captor.toString();
    }

    @Test
    void integrateTest() {
        final FuelTank fuelTank = new FuelTank(400);
        final CrewManager crewManager = new CrewManager(50);
        final SupplyHold supplyHold = new SupplyHold(crewManager, 1_000);
        final Engine engine = new Engine(fuelTank, 10);

        final Ship ship = new Ship(
                fuelTank,
                supplyHold,
                crewManager,
                engine
        );

        fuelTank.load(10);
        supplyHold.load(10);
        crewManager.load(10);

        supplyHold.allocateToCrew();

        engine.runForHours(4);

        ship.report();

        assertThat(output()).contains(
                "엔진을 4시간 동안 돌립니다!",
                "현재 연료는 370L 남아 있습니다.",
                "현재 물자는 950명 분이 남아 있습니다.",
                "현재 선원 60명이 있습니다."
        );
    }
}

 

변경하려는 이유, 즉 책임이 알맞게 분리되었는가?

앞으로 무수히 많은 객체를 만들게 될 겁니다.

책임이 알맞게 분리 되었는가는, 매 시간 매 초 마다 고민해야할 사항입니다.

변경하려는 범위, 즉 책임의 범위를 설정하는 것 또한 중요합니다.

책임의 범위를 너무 좁게 잡는다면, 많은 파일이 생기고 코드의 흐름을 파악하기가 어렵다는 단점이 있습니다.

책임의 범위를 너무 크게 잡는다면, 단일 책임 원칙(SRP)의 이점을 충분히 살리지 못합니다.

 

책임의 범위는 정답이 없습니다.

단일 책임 원칙 그 자체는, 책임의 범위를 정하는 근거로 사용될 수 없습니다.

단일 책임 원칙으로 충분한 효과를 볼 수 있도록 책임의 범위를 팀원들과 이야기하고 설정하는 것 또한

개발자의 역량이라고 생각합니다.

 

예제 코드는 모두 https://github.com/kor-Chipmunk/blogRepository/tree/main/SRPShipExample 에 있습니다.

댓글