SOLID 설계 원칙

2024. 8. 20. 10:57DesignPattern

단일 책임 원칙(SRP, Single Responsibility Principle)

단일 책임 원칙은 클래스가 하나의 책임만을 가져야 하며, 클래스는 그 책임을 완전히 캡슐화해야 한다는 원칙입니다. 하나의 책임만을 가져야 한다는 의미는 클래스가 변경할 이유가 오직 하나여야 한다는 의미입니다. 하나의 클래스가 여러 책임을 가지게 되면 각 책임이 변경될 때마다 클래스가 변경되어야 하므로 클래스가 변경에 취약해집니다.

즉, 단일 책임 원칙은 클래스가 여러개의 책임을 가진 상태에서 책임이 변경될 때마다 클래스가 수정의 영향을 받게 되기 때문에 하나의 책임만을 가져야 한다는 의미입니다.

다음 예제는 단일 책임 원칙이 적용되지 않은 예제입니다. User 클래스는 사용자의 데이터를 저장하면서, 동시에 데이터베이스와 통신하고, 사용자 데이터를 출력하는 기능을 수행하고 있습니다.

public class User {
	private final String name;
	private final String email;

	public User(String name, String email) {
		this.name = name;
		this.email = email;
	}

	public void saveToDatabase() {
		System.out.println("Saving user " + name + " to the database");
	}

	public void printUserDetails() {
		System.out.println("Name: " + name + ", Email: " + email);
	}
}

public class Main {
	public static void main(String[] args) {
		User kim = new User("kim", "nemo1234@gmail.com");
		kim.saveToDatabase();
		kim.printUserDetails();
	}
}

위 예제의 실행 결과는 다음과 같습니다.

Saving user kim to the database
Name: kim, Email: nemo1234@gmail.com

위 예제에서 User 클래스는 3가지의 책임을 가지고 있습니다. 3가지 책임은 다음과 같습니다.

  • 사용자 정보를 저장(saveToDatabase)
  • 사용자 정보 출력(printUserDetails)
  • 사용자 정보 관리(User 생성자)

위 3가지 책임은 모두 독립적이며, 변경될 이유도 다릅니다. 예를 들어 데이터베이스 로직이 변경되거나 출력 형식이 변경되면 User 클래스에서 변경되어야 하므로 이는 단일 책임 원칙을 위반하게 되는 것입니다.

위와 같은 단일 책임 원칙 위배를 해결하기 위해서 User 클래스의 각 책임을 별도의 클래스로 분리하여 책임을 명확히 하도록 합니다.

@Getter
public class User {
	private final String name;
	private final String email;

	public User(String name, String email) {
		this.name = name;
		this.email = email;
	}
}

public class UserRepository {
	private final List<User> store = new ArrayList<>();

	public void saveToDatabase(User user) {
		System.out.println("Saving user " + user.getName() + " to the database");
		store.add(user);
	}
}

public class UserPrinter {
	public void printUserDetails(User user) {
		System.out.println("Name: " + user.getName() + ", Email: " + user.getEmail());
	}
}

public class Main {
	public static void main(String[] args) {
		User kim = new User("kim", "nemo1234@gmail.com");
		UserRepository userRepository = new UserRepository();
		userRepository.saveToDatabase(kim);

		UserPrinter userPrinter = new UserPrinter();
		userPrinter.printUserDetails(kim);
	}
}

위 예제의 실행 결과는 다음과 같습니다.

Saving user kim to the database
Name: kim, Email: nemo1234@gmail.com

위 예제에서는 각 클래스가 하나의 책임만을 가지도록 분리했습니다.

  • User 클래스 : 사용자의 데이터(이름, 이메일)을 관리하는 책임
  • UserRepository 클래스 : 사용자 데이터를 데이터베이스에 저장하는 책임
  • UserPrinter 클래스 : 사용자 데이터를 출력하는 책임

주의할 점은 하나의 책임이 한 클래스에 하나의 기능을 의미하는 것은 아닙니다. 예를 들어 편의점 직원은 매장을 관리할 책임을 가지고 있습니다. 직원은 진열대에 물건을 채우는 기능을 가질수 있고, 손님의 물건을 계산할 수 있는 기능을 가질수 있습니다. 또한 매장을 청소할 수 있는 기능 또한 가지고 있습니다. 편의점 직원을 대상으로 책임과 기능을 정리하면 다음과 같습니다.

  • 클래스 : 편의점 직원
  • 책임 : 매장을 관리하는 것
  • 기능
    • 물건 채우기
    • 물건 계산
    • 매장 청소

그런데 만약 편의점 직원이 물건을 발주한다면 이 기능은 편의점 직원의 책임을 벗어난 기능입니다. 물건을 발주하는 것은 편의점 점주의 책임이기 때문입니다. 만약에 편의점 직원 또한 물건을 발주한다고 가정합니다. 그런데 물건을 발주하는 방식이 변경되었다면 편의점 점주뿐만 아니라 편의점 직원 또한 변경된 물건 발주 방식을 교육을 받아야 할 것입니다. 이 설명에서 물건을 발주하는 것은 하나의 기능에 해당하고 물건 발주 방식을 교육받는 것은 코드의 수정을 의미할 것입니다. 따라서 단일 책임 원칙에서 말하는 책임은 기능과 동일하지 않습니다.

개방-폐쇄 원칙(OPC, Open-Closed Principle)

개방-폐쇄 원칙은 기능을 추가하거나 확장할때 기존 코드를 수정하지 말아야 한다는 원칙입니다. 새로운 요구사항이나 변경 사항이 생길 때 기존의 코드를 변경하는 대신, 코드를 확장하여 그 요구사항을 처리할 수 있도록 설계해야 합니다. 코드를 확장한다는 의미는 기존 코드를 수정하는 것이 아닌 새로운 구현체 클래스를 생성하여 요구사항을 처리하는 것을 의미합니다.

즉, 개방-폐쇄 원칙은 기능을 추가할때 클라이언트 코드나 다른 구현체 클래스를 수정하지 않고 새로운 구현체 클래스를 생성하는 방식으로 기능을 추가하는 원칙입니다.

다음 예제는 개방-폐쇄 원칙을 적용하지 않은 예제입니다. 할인 정책을 구현하는 DiscountService 클래스가 존재합니다. 현재는 고정된 할인율을 적용하지만, 새로운 할인 정책이 필요할 때마다 DiscountService 클래스를 수정해야 할 것입니다.

public class DiscountService {
	public double calculateDiscount(String customerType, double price) {
		if (customerType.equals("Regular")) {
			return price * 0.05;
		} else if (customerType.equals("VIP")) {
			return price * 0.10;
		}
		return 0;
	}
}

public class Main {
	public static void main(String[] args) {
		DiscountService discountService = new DiscountService();
		double result = discountService.calculateDiscount("VIP", 1000.0);
		System.out.println(result);
	}
}

위 예제의 실행 결과는 다음과 같습니다.

100.0

위 예제에서 문제점은 새로운 고객의 타입이 추가될 때마다 DiscountService 클래스를 수정해야 합니다. 이는 수정에는 닫혀 있어야 한다는 개방-폐쇄 원칙에 위배됩니다.

위 문제점을 해결하기 위해서 다음과 같이 예제를 구현합니다.

public interface DiscountPolicy {
	double calculateDiscount(double price);
}

public class RegularCustomerDiscount implements DiscountPolicy{

	private final double discountRate;

	public RegularCustomerDiscount(double discountRate) {
		this.discountRate = discountRate;
	}

	@Override
	public double calculateDiscount(double price) {
		return price * discountRate;
	}
}

public class VipCustomerDiscount implements DiscountPolicy{

	private final double discountRate;

	public VipCustomerDiscount(double discountRate) {
		this.discountRate = discountRate;
	}

	@Override
	public double calculateDiscount(double price) {
		return price * discountRate;
	}
}

public class DiscountService {

	public double applyDiscount(DiscountPolicy discountPolicy, double price) {
		return discountPolicy.calculateDiscount(price);
	}
}

public class Main {
	public static void main(String[] args) {
		DiscountService discountService = new DiscountService();
		VipCustomerDiscount vipCustomerDiscount = new VipCustomerDiscount(0.10);
		double result = discountService.applyDiscount(vipCustomerDiscount, 1000.0);
		System.out.println(result);
	}
}

위 예제의 실행 결과는 다음과 같습니다.

100.0

위와 같이 DiscountPolicy 인터페이스를 정의하고 고객의 타입별로 구현체를 구현하면 더이상 DiscountService 클래스를 수정할 일이 없습니다. 예를 들어 요구사항이 추가되어 고객의 타입에 Gold 타입이 추가되었다고 가정합니다. 그렇다면 DiscountService를 수정할 필요 없이 다음과 같이 클래스를 추가하면 됩니다.

public class GoldCustomerDiscount implements DiscountPolicy{
	private final double discountRate;

	public GoldCustomerDiscount(double discountRate) {
		this.discountRate = discountRate;
	}

	@Override
	public double calculateDiscount(double price) {
		return price * discountRate;
	}
}

개방-폐쇄 원칙을 적용했을 때의 장점은 다음과 같습니다.

  • 요구사항이 변경되어 고객의 타입이 추가되어도 DiscountService 클래스를 수정하지 않아도 됩니다.
  • 새로운 고객의 타입을 추가하여도 DiscountPolicy 인터페이스의 다른 구현체(RegularCustomerDiscount, VipCustomerDiscount) 클래스들을 수정할 필요가 없습니다.
  • 기능의 추가가 DiscountService의 메서드를 수정하는 것이 아닌 GoldCustomerDiscount 구현체 클래스를 생성을 통해서 확장하기 때문에 유지보수가 쉽습니다.

리스코프 치환 원칙(LSP, Liskov Substitution Principle)

리스코프 치환 원칙은 상위 클래스의 객체가 동작을 하위 클래스의 객체가 해도 프로그램 동작에 문제가 없어야 한다는 원칙입니다. 리스코프 치환 원칙의 핵심은 다음과 같습니다.

  • 대체 가능성 : 하위 클래스 객체는 상위 클래스 객체의 모든 메서드를 동일하게 수행해야 합니다.
  • 일관된 행동 : 하위 클래스 객체는 상위 클래스 객체의 메서드 시그니처(동작 방식, 인자, 반환값 등)을 준수해야 합니다.
  • 안전한 확장 : 하위 클래스 객체가 상위 클래스 객체의 기능을 확장하거나 변경할수는 있지만, 기본적으로 상위 클래스 객체의 성질을 유지해야 합니다.

다음 예제는 리스코프 치환 원칙을 위반한 예제입니다.

public class Rectangle {
	protected int width;
	protected int height;

	public Rectangle(int width, int height) {
		this.width = width;
		this.height = height;
	}

	public void setWidth(int width) {
		this.width = width;
	}

	public void setHeight(int height) {
		this.height = height;
	}

	public int getArea() {
		return width * height;
	}
}

public class Square extends Rectangle {

	public Square(int width, int height) {
		super(width, height);
	}

	@Override
	public void setWidth(int width) {
		this.width = width;
		this.height = width;
	}

	@Override
	public void setHeight(int height) {
		this.width = height;
		this.height = height;;
	}
}

public class Main {
	public static void main(String[] args) {
		Rectangle rectangle = new Rectangle(2, 4);
		int area = rectangle.getArea();
		System.out.println("Rectangle Area: " + area);

		Rectangle square = new Square(2, 2);
		square.setWidth(4);
		int area2 = square.getArea();
		System.out.println("Square Area: " + area2);
	}
}

위 예제의 실행 결과는 다음과 같습니다.

Rectangle Area: 8
Square Area: 16

위 예제에서 Rectangle 타입으로 Square 객체를 저장하게 되면 저장한 Rectangle 타입의 객체가 의도대로 동작하지 않을 수 있습니다. 클라이언트 입장에서는 직사각형이라고 생각하고 2*2 길이에서 가로 길이를 4로 변경한 다음에 넓이를 계산하였을 때 넓이 8을 기대하였지만 실제 수행결과는 16이 계산된 것을 볼수 있습니다. 이는 실제 저장된 객체가 Square 타입으로써 setWidth 메서드 호출시 가로 길이 뿐만 아니라 세로 길이 또한 정사각형의 성질을 유지하기 위해서 변경하였기 때문입니다. 따라서 상위 클래스 객체의 동작을 하위 클래스가 동작에 실패하였기 때문에 이는 리스코프 치환 원칙에 위배됩니다.

다음 예제는 리스코프 치환 원칙을 반영한 예제입니다. 위와 같은 문제를 해결하기 위해서는 Rectangle과 Square가 상속 관계가 아닌 별도의 클래스로 분리하는 것이 좋습니다.

public class Rectangle {
	private int width;
	private int height;

	public Rectangle(int width, int height) {
		this.width = width;
		this.height = height;
	}

	public void setWidth(int width) {
		this.width = width;
	}

	public void setHeight(int height) {
		this.height = height;
	}

	public int getArea() {
		return width * height;
	}
}

public class Square {

	private int side;

	public Square(int side) {
		this.side = side;
	}

	public void setSide(int side) {
		this.side = side;
	}

	public int getArea() {
		return side * side;
	}
}

public class Main {
	public static void main(String[] args) {
		Rectangle rectangle = new Rectangle(2, 4);
		int area = rectangle.getArea();
		System.out.println("Rectangle Area: " + area);

		Square square = new Square(2);
		square.setSide(4);
		int area2 = square.getArea();
		System.out.println("Square Area: " + area2);
	}
}

위 예제의 실행 결과는 다음과 같습니다.

Rectangle Area: 8
Square Area: 16

Rectangle과 Square 클래스를 별도로 분리하였기 때문에 클라이언트 입장에서는 직사각형과 정사각형의 성질을 헷갈릴 필요없이 기대한 대로 결과가 나오게 됩니다.

인터페이스 분리 원칙(ISP, Interface Segregation Principle)

인터페이스 분리 원칙은 하나의 큰 인터페이스를 여러개의 작은 인터페이스로 분리해서 클라이언트가 필요로 하는 메서드만 알 수 있도록 설계해야 한다는 원칙입니다. 너무 많은 기능을 담고 있는 인터페이스는 클라이언트에 불필요한 메서드를 노출하게 되어 수정에 영향을 미칠 수 있습니다. 이와 같은 문제를 해결하기 위해서는 인터페이스를 기능 단위로 분리하는 것이 좋습니다.

예를 들어 다음과 같이 Worker라는 인터페이스와 해당 인터페이스를 구현하는 HumanWorker, RobotWorker 구현체 클래스가 존재합니다.

public interface Worker {
	void work();
	void eat();
}

@Slf4j
public class HumanWorker implements Worker {

	private String name;

	public HumanWorker(String name) {
		this.name = name;
	}

	@Override
	public void work() {
		log.info("{} is working", name);
	}

	@Override
	public void eat() {
		log.info("{} is eating",name);
	}
}

@Slf4j
public class RobotWorker implements Worker{

	private String id;

	public RobotWorker(String id) {
		this.id = id;
	}

	@Override
	public void work() {
		log.info("id {} robot is working", id);
	}

	@Override
	public void eat() {
		throw new UnsupportedOperationException("Robot does not eat");
	}
}

@Slf4j
public class Main {
	public static void main(String[] args) {
		Worker human = new HumanWorker("kim");
		human.work();
		human.eat();

		Worker robot = new RobotWorker("R1");
		robot.work();
		try{
			robot.eat();
		}catch (UnsupportedOperationException e){
			log.error(e.getMessage());
		}
	}
}

위 예제의 실행 결과는 다음과 같습니다.

12:27:54.816 [main] INFO nemo.ch1.class05.step01.before.HumanWorker -- kim is working
12:27:54.820 [main] INFO nemo.ch1.class05.step01.before.HumanWorker -- kim is eating
12:27:54.821 [main] INFO nemo.ch1.class05.step01.before.RobotWorker -- id R1 robot is working
12:27:54.821 [main] ERROR nemo.ch1.class05.step01.before.Main -- Robot does not eat

위 예제에서 RobotWorker 구현체 클래스는 eat() 메서드는 필요없지만 Worker 인터페이스에 의해서 강제적으로 구현해야 합니다. 이와 같은 설계는 인터페이스 분리 원칙에 위반됩니다.

위와 같은 문제점을 해결하기 위해서 Worker 인터페이스의 기능들을 용도별로 쪼개서 작은 인터페이스들로 분리합니다. 다음 예제는 Worker 인터페이스를 기능별로 쪼갠 예제입니다.

public interface Workable {
	void work();
}

public interface Eatable {
	void eat();
}

@Slf4j
public class HumanWorker implements Workable, Eatable {

	private String name;

	public HumanWorker(String name) {
		this.name = name;
	}

	@Override
	public void work() {
		log.info("{} is working", name);
	}

	@Override
	public void eat() {
		log.info("{} is eating",name);
	}
}

@Slf4j
public class RobotWorker implements Workable {

	private String id;

	public RobotWorker(String id) {
		this.id = id;
	}

	@Override
	public void work() {
		log.info("id {} robot is working", id);
	}
}

@Slf4j
public class Main {
	public static void main(String[] args) {
		HumanWorker human = new HumanWorker("kim");
		human.work();
		human.eat();

		RobotWorker robot = new RobotWorker("R1");
		robot.work();
	}
}

위 예제의 실행 결과는 다음과 같습니다.

12:32:39.013 [main] INFO nemo.ch1.class05.step01.after.HumanWorker -- kim is working
12:32:39.021 [main] INFO nemo.ch1.class05.step01.after.HumanWorker -- kim is eating
12:32:39.022 [main] INFO nemo.ch1.class05.step01.after.RobotWorker -- id R1 robot is working

위 예제에서 HumanWorker 클래스는 work()와 eat() 모두 필요하므로 두 인터페이스를 모두 구현하고, RobotWorker 클래스는 work() 기능만 필요하므로 Workable 인터페이스만 구현하면 됩니다. 이와 같이 설계하면 각 클래스가 자신에게 필요한 인터페이스만 구현하게 되어 불필요한 메서드를 구현할 필요가 없습니다.

의존 역전 원칙(DIP, Dependency Inversion Principle)

의존 역전 원칙은 객체가 다른 객체에 의존하는 경우 구현체 타입에 의존하지 말고 추상화된 클래스나 인터페이스를 의존해야 한다는 원칙입니다. 추상 클래스나 인터페이스에 의존하게 되면 다른 구현체 객체를 전달해도 동작합니다. 또한 요구사항이 추가되어 새로운 구현체를 확장하여도 기존 코드에 영향을 미치지 않습니다.

public class Light {
	public void turnOn() {
		System.out.println("Light is on");
	}

	public void turnOff() {
		System.out.println("Light is off");
	}
}

public class Switch {
	private Light light;

	public Switch(Light light) {
		this.light = light;
	}

	public void operate() {
		light.turnOn();
		// ... 추가 로직
		light.turnOff();
	}
}

public class Main {
	public static void main(String[] args) {
		Light light = new Light();
		Switch lightSwitch = new Switch(light);
		lightSwitch.operate();
	}
}

위 예제의 실행 결과는 다음과 같습니다.

Light is on
Light is off

위 예제에서 Switch 객체는 Light 객체를 의존하고 있습니다. 이 경우에 Light 클래스가 변경되거나 다른 종류의 전등(예: 스마트 전등)을 사용하고자 한다면 Switch 클래스 또한 수정해야 합니다. 이러한 이유로 이 의존관계는 의존 역전 원칙을 위반하고 있습니다.

다음 예제는 의존 역전 원칙을 준수하는 예제입니다.

public interface Switchable {
	void turnOn();
	void turnOff();
}

public class Light implements Switchable{
	@Override
	public void turnOn() {
		System.out.println("Light is turned on");
	}

	@Override
	public void turnOff() {
		System.out.println("Light is turned off");
	}
}

public class Fan implements Switchable{
	@Override
	public void turnOn() {
		System.out.println("Fan is turned on");
	}

	@Override
	public void turnOff() {
		System.out.println("Fan is turned off");
	}
}

public class Switch {
	private Switchable switchable;

	public Switch(Switchable switchable) {
		this.switchable = switchable;
	}

	public void operate() {
		switchable.turnOn();
		// ... 추가 로직
		switchable.turnOff();
	}
}

public class Main {
	public static void main(String[] args) {
		Switchable light = new Light();
		Switch s1 = new Switch(light);
		s1.operate();

		Switchable fan = new Fan();
		Switch s2 = new Switch(fan);
		s2.operate();
	}
}

위 예제의 실행 결과는 다음과 같습니다.

Light is turned on
Light is turned off
Fan is turned on
Fan is turned off

위 예제에서 의존 역전 원칙을 준수하기 위해서 Switchable 인터페이스를 정의함으로써 Switch 클래스는 특정 구현 클래스에 의존하지 않게 되어 Light, Fan 구현체 클래스가 변경되어도 영향을 받지 않게 됩니다. 또한 추가적인 구현체가 확장되어도 Switch 클래스를 수정할 필요가 없습니다.