소프트웨어 개발 원칙 - low-hill/Knowledge GitHub Wiki

간결하고 가독성이 좋으며 유지 관리 및 확장 가능한 품질 좋은 코드를 작성하기 위한 원칙들을 정리한다.


1. DRY (Don't Repeat Yourself)

코드의 반복을 줄이기 위한 핵심 원칙이다. 이는 프로그램에서 코드 중복을 피하고 재사용 가능한 코드를 작성해야 한다. 동일한 코드 블록을 두 번 이상 복제하는 경우 추상화에 대해 생각해야 한다.

예로 오늘, 한달전, 한달전을 동일한 날짜 형식으로 구하는 함수가 각각 존재한다면 이를 하나의 함수로 작성하는것이 더 좋다.


2. KISS (Keep it Simple, Stupid)

소프트웨어 개발에서 단순성을 강조하고 불필요하게 복잡해지는것을 경계하라는 원칙이다. 소프트웨어 설계나 코딩 시 간단하고 단순하게 만들기 위해 노력해야 한다. 예를 들어, 복잡한 if-else문을 작성했다면 스위치 문이나 열거형을 사용하여 구조를 단순화하고 가독성 있게 만들 수 있다.

public String getErrorMessage(String errorCode) {
  if ("E001".equals(errorCode)) {
    return "Invalid parameter";
  } else if("E002".equals(errorCode)) {
    return "Database error";
  } else if("E003".equals(errorCode)) {
    return "...";
  } else if("E004".equals(errorCode)) {
    return "...";
  } else {
    return "Unknown error";
  }
}
  • 위 코드를 Enum으로 작성하면 가독성이 좋아진다.
public enum ErrorCode {
  INVALID_PARAMETER("E001", "Invalid parameter"),
  DATABASE_ERROR("E002", "Database error"),
  ...,
  UNKNOWN_ERROR("E004", "Unknown error");

  private String code;
  private String errorMessage;

  ErrorCode(String code, String errorMessage) {
    this.code = code;
    this.errorMessage = errorMessage;
  }

  @JsonCreator
  public static ErrorCode ofValue(String errorCode){
    return Arrays.stream(ErrorCode.values())
                .filter(o -> o.getCode().equals(errorCode))
                .findFirst()
                .orElse(null);
  }
}

3.YAGNI (You Aren't Gonna Need It)

현재 필요한 기능만 추가해야 한다. 현재 필요하지 않거나 향후에 필요할 수 있는 기능에 대한 코드를 작성하면 분석이 어려줘져 버그 발생 가능성이 커진다.


4. Separation of Concerns (SoC)

프로그램을 각각 특정 관심사나 책임을 처리하는 독립적인 부분으로 나누는 원칙이다. 프로그램의 각 부분는 한 가지 작업에 집중하고 다른 요소와 책임을 공유하면 안되고 해당 요소와 관련없는 작업은 포함시키지 않음으로 다른 요소에 얽매이지 않아야 한다. 해당 원칙은 프로그램의 유지보수를 편리하게 할 수 있고 코드의 적절한 모듈화로 재사용성이 높아진다.

사용자가 계정 생성 및 로그인할 수 있는 웹 어플리케이션 기능 개발을 예로 들때, 관심사 분리 원칙을 적용하면 계정 생성 기능과 로그인 기능을 분리할 수 있다. 즉, 각 요소를 독립적으로 처리하기 위해 별도의 모듈로 만들어 사용자 계정 생성을 담당하는 코드는 해당 작업에만 집중하고 로그인을 담당하는 코드는 인증 및 권한 부여를 처리할 수 있다.
웹 어플리케이션에서 날씨 정보 가시화를 예로 들때, 관심사 분리 원칙을 적용하면 날씨정보 api 호출하는 data fetching 모듈, localStorage에 데이터 저장하는 데이터 저장 모듈, 날씨정보를 렌더링하는 user interface 모듈로 분리할 수 있다.


5. Law of Demeter

디미터 법칙은 객체는 내부적으로 보유하고 있거나 메시지를 통해 확보한 정보만 가지고 의사 결정을 내려야 하고 다른 객체의 구조나 속성에 대해 제한된 이해를 가져야 한다. 디미터 법칙은 Don’t Talk to Strangers(낯선 이에게 말하지 마라) 또는 한 객체가 알아야 하는 다른 객체를 최소한으로 유지하라는 의미로 Principle of least knowledge(최소 지식 원칙)라고도 불린다.

디미터 법칙은 객체 간 관계를 설정할 때 객체 간의 결합도를 효과적으로 낮출 수 있는 유용한 지침 중 하나로 꼽히며 객체 지향 생활 체조 원칙 중 한 줄에 점을 하나만 찍는다.로 요약되기도 한다.

디미터의 법칙에서 코드 내 메서드는 아래와 같은 두 가지 요건을 충족해야 한다.

  • 메서드의 인자로 전달된 객체에 대해서만 접근
  • 클래스 내부에 정의된 멤버변수에 접근

위와 같이 메서드에 직접 알려지지 않은 것에 대해 접근하면 안 된다.


6. SOLID Principles

객체 지향 프로그래밍 및 설계 시 반영되어야 할 5가지 기본 원칙으로, 시간이 지나도 유지보수와 확장이 쉬운 시스템을 만들고자 할 때 적용할 수 있다.

단일 책임 원칙 : Single Reponsibility Priciple (SRP)

하나의 클래스는 하나의 책임만 가져야 한다. 클래스를 변경할 이유가 하나만 있어야 하며, 이는 클래스에는 하나의 직업만 있어야 한다는 뜻입니다. SRP의 핵심은 시스템을 특정 기능을 담당하는 한 부분으로 나누고 한 클래스가 하나의 작업만 처리하도록 하는 것이다. 한 클래스를 한 관심사에 집중하도록 유지하는 것은 클래스를 더욱 튼튼하게 만든다. 다른 시기에 다른 이유로 변경되어야 하는 두 가지를 묶는 것은 나쁜 설계일 수 있다.
아래와 같이 사용자 클래스에 로그인과 메일 발송 두가지 메소드가 있다면, 하나의 클래스가 2개의 책임을 가지고 있기 때문에 단일 책임 원칙을 위배 한다.

public class User {
    public void login() {
        // Login logic
    }

    public void sendEmail() {
        // Email sending logic
    }
}

위 코드를 단일 책임 원칙에 맞게 Refactoring하면 아래와 같다.

public class UserLogin {
    public void login() {
        // Login logic
    }
}

public class EmailSender {
    public void sendEmail() {
        // Email sending logic
    }
}

위와 같이 단일 책임 원칙을 지킨 코드일 경우 개발자가 코드를 분석하고 수정하기 쉬워지므로 코드 가독성 및 유지보수성 향상 된다. 또한, 클래스 내 책임이 잘 정의되어 있어 디버깅 및 단위 테스트에 용이하다.
SRP를 지나치게 준수하면 소규모 클래스가 많아져 잠재적으로 복잡성을 초래할 수 있다. 따라서 소프트웨어 프로젝트의 구체적인 요구 사항과 규모를 고려하여 SRP를 신중하게 적용헤야 한다.

개방-폐쇄 원칙 : Open/Closed Principle (OCP)

'소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다'는 프로그래밍 원칙이다. 즉, 기능을 추가하거나 변경해야 할 때 제대로 동작하고 있던 기존의 코드를 변경하지 않고, 새로운 코드를 추가함으로써 기능의 추가나 변경이 가능해야 한다. OCP는 변화에 적응할 수 있으면서 기존 기능을 유지하는 소프트웨어를 만드는데 도움이 되는 원칙으로 작용한다. 예로 아래 코드를 볼 때 목적에 부합하는 것처럼 보이지만, 할인 계산 규칙을 변경하거나 확장하려면 클래스를 직접 수정해야 하므로 OCP를 위반하게 된다.

public class DiscountCalculator {
    public double calculateDiscount(Order order) {
        // Discount calculation logic
    }
}

위 코드를 개발-폐쇄 원칙에 맞게 Refactoring하면 아래와 같다.

public abstract class DiscountCalculator {
    public abstract double calculateDiscount(Order order);
}

public class VIPDiscountCalculator extends DiscountCalculator {
    @Override
    public double calculateDiscount(Order order) {
        // VIP discount calculation logic
    }
}

위 코드에서는 추상 클래스로 VIPDiscountCalculator와 같은 다양한 할인 계산 규칙 구현할 수 있도록 함으로써 기존 코드를 변경하지 않고도 새로운 할인 전략을 추가할 수 있다. 개방-폐쇄 원칙을 적용하면 기존 코드를 수정하지 않기에 실수로 새로운 버그 발생 및 기존 기능에 이슈가 발생 할 가능성이 크게 줄어든다. 또한, 새로운 기능 추가 시 기존의 테스트를 거친 유닛 기반으로 구축할 수 있으므로 재사용 및 개발 속도가 빨라진다.

OCP는 확장성이 뛰어나지만, 복잡성을 초래할 수 있는 더 작고 전문화된 클래스가 많아짐으로 프로젝트의 구체적인 요구 사항과 규모에 따라 OCP를 신중하게 적용해야 한다.

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

"프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다."    

리스코프 치환 원칙은 올바른 상속을 위해 자식 객체는 부모객체로 교체할 수 있어야 한다는 원칙이다. 즉, S가 T의 자식객체면 T의 객체를 S의 객체로 교체해도 프로그램은 정상 작동해야 한다.

아래 코드에서 Bird 클래스는 fly 메서드를 갖고 있고 Ostrich 클래스는 Bird를 상속한다. 그러나 타조는 날지 못하므로 fly 메서드는 타조와 관련이 없다. 이는 부모 글래스의 메소드를 유지하지 못하므로 LSP의 본질에 위배된다.

public class Bird {
    public void fly() {
        // Flying logic
    }
}

public class Ostrich extends Bird {
    // Ostriches cannot fly, but they inherit the fly method
}

위 코드를 리스코프 치환 원칙에 맞게 Refactoring하면 아래와 같다.

public abstract class Bird {
    public abstract void move();
}

public class Sparrow extends Bird {
    @Override
    public void move() {
        // Flying logic
    }
}

public class Ostrich extends Bird {
    @Override
    public void move() {
        // Running logic
    }
}

이 리팩터링된 코드에서는 Bird 클래스에 추상 메서드 move를 도입하여 특정 동작을 강조하는 대신 다양한 동작을 포괄할 수 있는 공통 행위를 정의하였다. Sparrow 클래스는 Bird를 상속받아 Flying 로직을 지정하고, Ostrich 클래스는 Running 동작을 구현한다. 이제 자식 클래스가 부모클래스를 대체할 수 있기 때문에 LSP를 준수한다.

LSP 준수 시 클래스 생성 목적에 맞게 설계하기 때문에 예상치 못한 문제를 일으키지 않으면서 서브클래스의 확장 및 대체할 수 있다.
LSP를 과도하게 준수하면 당장 필요하지 않은 메서드를 서브클래스에 포함하는 YAGNI 원칙에 어긋날 수 있다. 또한, 여러 서브클래스에 대한 공통 인터페이스를 만들려고 할 때, 특정 서브클래스에만 관련된 메서드를 포함하게 되면 ISP 원칙에 위배될 수 있기 때문에 해당 문제점들을 고려하여 적용해야 한다.

인터페이스 분할 원칙 : Interface Segregation Principle (ISP)

"특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다."

클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다. 인터페이스 분리 원칙은 모든 것을 포괄하는 큰 덩어리의 인터페이스들을 구체적이고 작은 단위들로 분리시킴으로써 클라이언트들이 꼭 필요한 메서드들만 이용할 수 있게 한다. ISP를 구현할 때 핵심은 관리 가능하고 의미 있는 상태를 유지하면서 클라이언트의 진정한 책임을 반영하는 인터페이스를 만드는 것이다.

아래 코드는 ISP를 위반하는 예로, 모든 구현 클래스가 필요 여부에 관계없이 포함되어 있는 3개 메서드를 모두 정의해야한다. 이는 클라이언트가 불필요한 메소드를 구현하도록 강요하므로 ISP를 위반한다.

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

위 코드를 인터페이스 분할 원칙에 맞게 Refactoring하면 아래와 같다.

public interface Worker {
    void work();
}

public interface Eater {
    void eat();
}

public interface Swimmer {
    void eat();
}

위와 같이 리팩터링된 코드에서는, 각 인터페이스는 맡은 역할에 따라 단일 책음을 갖게 된다. 또한, 클라이언트는 자신이 필요한 메소드에만 의존하므로 사용하지 않는 메소드를 구현할 필요도, 변경에 대해 영향을 받는 이유도 없어지게 된다.

의존관계 역전 원칙 : Dependency Inversion Principle (DIP)

“추상화에 의존해야지, 구체화에 의존하면 안된다.”    

상위 계층이 하위 계층에 의존하는 전통적인 의존관계를 반전시킴으로써, 상위 계층이 하위 계층의 구현으로부터 독립되게 SW 모듈을 분리하는 원칙이다. DIP는 상위 모듈이 하위 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다. 이 원칙은 '상위와 하위 객체 모두가 동일한 추상화에 의존해야 한다'는 객체 지향적 설계의 대원칙을 제공한다.

아래 코드는 DIP를 위반하는 예로, SwitchDevice는 LightBulb라는 구체적인 객체에 의존하게 되는데 디바이스 종류가 변경될 경우 SwitchDevice 클래스를 수정해야 한다.

public class LightBulb {
    public void turnOn() {
        // Turn on logic
    }
}

public class SwitchDevice {
    private LightBulb bulb;

    public void operate(LightBulb bulb) {
        // control the device
    }
}

위 코드를 인터페이스 분할 원칙에 맞게 Refactoring하면 아래와 같다.

public interface Switchable {
    void turnOn();
}

public class LightBulb implements Switchable {

    @Override
    public void turnOn() {
        // Turn on logic
    }
}

public class SwitchDevice {
    private Switchable device;

    SwitchDevice(Switchable device) {
        this.device = device;
    }

    public void operate() {
        // control the device
    }
}

위와 같이 리팩터링된 코드에서는, SwitchDevice는 구체적인 객체에 직접 의존하지 않고 Switchable라는 인터페이스와 의존 관계를 맺기에 구체적인 객체(전환가능한 장치)들도 상위 개념인 Switchable 인터페이스에 의존한다.

DIP를 적용하면 모듈 간의 직접적인 종속성을 최소화하여 보다 모듈화되고 유지 보수가 용이하지만, 모든 클래스에 인터페이스를 생성하면 그에 따른 클래스가 증가하여 복잡해지므로 필요한 것만 생성 해야 한다.

Reference