2025-08-31 12:29

  • 프로그래밍 디자인 원칙은 변화에 유연하고 유지보수하기 쉬운 소프트웨어를 만들기 위한 경험에서 비롯된 지침입니다.

  • SOLID, KISS, DRY와 같은 핵심 원칙들은 코드의 복잡성을 줄이고, 역할과 책임을 명확히 하여 코드 품질을 높이는 것을 목표로 합니다.

  • 이 원칙들을 나침반 삼아 코드를 작성하면, 장기적으로 프로젝트의 안정성과 확장성을 크게 향상시킬 수 있습니다.

견고한 소프트웨어 설계의 비밀: 프로그래밍 디자인 원칙 완벽 가이드

소프트웨어 개발은 단순히 코드를 작성하는 행위를 넘어, 하나의 정교한 건축물을 짓는 것과 같습니다. 멋진 아이디어가 있어도, 튼튼한 설계 도면 없이는 모래성을 쌓는 것과 다를 바 없죠. 프로젝트의 규모가 커지고 시간이 흐를수록 초기의 잘못된 설계는 눈덩이처럼 불어나 결국 프로젝트 전체를 위협하는 기술 부채(Technical Debt)가 됩니다.

그렇다면 어떻게 해야 변화에 유연하고, 누가 봐도 이해하기 쉬우며, 오랫동안 건강하게 유지될 수 있는 소프트웨어를 만들 수 있을까요? 그 해답은 수십 년간 수많은 개발자들의 피와 땀으로 증명된 ‘프로그래밍 디자인 원칙’에 있습니다.

이 핸드북은 여러분이 더 나은 개발자로 성장하는 데 든든한 나침반이 되어줄 핵심적인 프로그래밍 디자인 원칙들을 소개합니다. 왜 이런 원칙들이 탄생했는지부터 시작해, 각각의 원칙이 무엇을 의미하고 어떻게 코드에 적용할 수 있는지 구체적인 예시와 함께 깊이 있게 탐험해 보겠습니다.

1. 디자인 원칙은 왜 필요할까? (탄생 배경)

1960년대 후반, 소프트웨어 프로젝트들은 예측 불가능한 일정 지연, 예산 초과, 낮은 품질이라는 공통적인 문제에 직면했습니다. 이를 ‘소프트웨어 위기(Software Crisis)‘라고 부릅니다. 사람들은 하드웨어의 발전 속도를 소프트웨어 개발이 따라가지 못하는 이유를 고민하기 시작했습니다.

결론은 ‘복잡성 관리의 실패’였습니다. 체계적인 설계 없이 주먹구구식으로 기능을 추가하다 보니, 코드는 서로 복잡하게 얽히고설켜 작은 수정 하나가 예상치 못한 곳에서 버그를 일으키는 ‘스파게티 코드’가 되어버렸습니다.

이러한 혼돈 속에서 개발자들은 경험을 통해 ‘좋은 소프트웨어’가 가진 공통적인 특징을 발견하고 이를 원칙으로 정립하기 시작했습니다. 디자인 원칙은 ‘이렇게 코딩해야만 해!’라는 엄격한 법률이 아니라, “과거에 우리가 이렇게 해보니 장기적으로 더 좋았어” 라는 경험에서 비롯된 현명한 가이드라인입니다. 이 원칙들을 따르면 코드의 유지보수성, 재사용성, 확장성이 극적으로 향상되어 소프트웨어의 생명력을 길게 늘릴 수 있습니다.

2. 반드시 기억해야 할 핵심 원칙들

수많은 디자인 원칙이 있지만, 그중에서도 가장 근본적이고 널리 사용되는 원칙들이 있습니다. 이들은 모든 개발자가 반드시 숙지하고 체화해야 할 기본기와 같습니다.

KISS 원칙 (Keep It Simple, Stupid)

“단순함이 궁극의 정교함이다.” - 레오나르도 다빈치

KISS 원칙은 ‘가능한 한 간단하고 멍청하게 유지하라’는 의미로, 불필요한 복잡성을 피하는 것이 가장 좋다는 철학입니다. 개발자들은 종종 미래의 모든 가능성을 대비해 지나치게 복잡하고 유연한 코드를 작성하려는 유혹에 빠집니다. 하지만 이러한 코드는 이해하기 어렵고, 버그가 발생하기 쉬우며, 실제로는 사용되지 않는 경우가 많습니다.

핵심 아이디어:

  • 코드는 복잡하게 작성하는 것보다 간단하게 작성하는 것이 훨씬 어렵고 가치 있다.

  • 지금 당장 필요한 가장 단순한 방법으로 문제를 해결하라.

나쁜 예시 (복잡한 방식):

# 사용자의 역할을 확인하는 복잡한 삼항 연산자
def get_user_role(user):
    return "Admin" if user.is_admin else "Editor" if user.is_editor else "User"

좋은 예시 (KISS 원칙 적용):

# 명확하고 읽기 쉬운 if-elif-else 구문
def get_user_role(user):
    if user.is_admin:
        return "Admin"
    elif user.is_editor:
        return "Editor"
    else:
        return "User"

두 코드는 같은 기능을 하지만, 아래 코드가 훨씬 더 명확하고 이해하기 쉽습니다.

DRY 원칙 (Don’t Repeat Yourself)

DRY 원칙은 ‘반복하지 말라’는 의미로, 시스템 내의 모든 지식은 단일하고, 모호하지 않으며, 신뢰할 수 있는 표현을 가져야 한다는 원칙입니다. 쉽게 말해, 똑같은 코드를 복사-붙여넣기 하지 말라는 뜻입니다.

코드 중복은 유지보수의 악몽을 초래합니다. 만약 중복된 코드에 버그가 발견되거나 로직 변경이 필요하다면, 관련된 모든 곳을 찾아 일일이 수정해야 합니다. 하나라도 놓치면 시스템은 미묘한 버그를 품게 됩니다.

핵심 아이디어:

  • 코드, 데이터, 로직의 중복을 제거하고 하나의 ‘진실의 원천(Single Source of Truth)‘을 유지하라.

  • 중복이 보이면 함수, 클래스, 모듈 등으로 추상화하라.

나쁜 예시 (코드 중복):

function processOrder(order) {
    // 10% 할인 계산
    const discount = order.price * 0.1;
    const finalPrice = order.price - discount;
    console.log(`최종 가격: ${finalPrice}`);
}

function processSpecialOrder(order) {
    // 10% 할인 계산 (코드 중복!)
    const discount = order.price * 0.1;
    const finalPrice = order.price - discount;
    // 추가 로직
    console.log(`특별 주문 최종 가격: ${finalPrice}`);
}

좋은 예시 (DRY 원칙 적용):

function calculateFinalPrice(price) {
    const discount = price * 0.1;
    return price - discount;
}

function processOrder(order) {
    const finalPrice = calculateFinalPrice(order.price);
    console.log(`최종 가격: ${finalPrice}`);
}

function processSpecialOrder(order) {
    const finalPrice = calculateFinalPrice(order.price);
    // 추가 로직
    console.log(`특별 주문 최종 가격: ${finalPrice}`);
}

할인 계산 로직을 calculateFinalPrice 함수로 분리하여 중복을 제거했습니다. 이제 할인 정책이 바뀌면 이 함수 하나만 수정하면 됩니다.

YAGNI 원칙 (You Ain’t Gonna Need It)

YAGNI 원칙은 ‘어차피 필요 없을 거야’라는 의미로, 지금 당장 필요하지 않은 기능은 만들지 말라는 원칙입니다. KISS 원칙과도 일맥상통하며, 특히 애자일 개발 방법론에서 강조됩니다.

개발자들은 종종 “나중에 이런 기능이 필요할지도 몰라”라는 생각으로 오버엔지니어링(over-engineering)을 하곤 합니다. 하지만 대부분의 예측은 빗나가고, 미리 만들어 둔 기능은 사용되지 않은 채 코드베이스를 복잡하게만 만드는 쓰레기가 될 가능성이 높습니다.

핵심 아이디어:

  • 현재 요구사항에만 집중하고, 미래를 예측하여 코드를 추가하지 마라.

  • 기능이 정말로 필요해지는 그 순간에 추가하는 것이 가장 효율적이다.

3. 객체 지향 설계의 5가지 보석: SOLID 원칙

SOLID 원칙은 객체 지향 프로그래밍(OOP) 및 설계의 핵심을 이루는 다섯 가지 원칙의 앞 글자를 딴 것입니다. 소프트웨어 엔지니어 로버트 C. 마틴(Robert C. Martin, “Uncle Bob”)이 2000년대 초에 정립했으며, 유지보수와 확장이 용이한 시스템을 만드는 데 결정적인 역할을 합니다.

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

“하나의 클래스는 단 하나의 변경 이유만을 가져야 한다.”

어떤 클래스를 변경해야 하는 이유가 여러 개라면, 그 클래스는 여러 책임을 가지고 있다는 신호입니다. SRP는 클래스가 단 하나의 책임(기능)에만 집중하도록 분리하는 것을 강조합니다.

책임이 분리되면 코드를 이해하기 쉽고, 하나의 변경이 다른 부분에 미치는 영향(side effect)을 최소화할 수 있습니다.

나쁜 예시 (여러 책임을 가진 클래스):

// 직원 데이터 관리와 보고서 생성 책임을 모두 가짐
class Employee {
    public String name;
    public double salary;

    public void saveToDatabase() {
        // 데이터베이스 저장 로직
    }

    public String generateReport() {
        // 보고서 생성 로직 (HTML, XML 등)
    }
}

Employee 클래스는 직원의 정보를 담는 책임과, 그 정보를 DB에 저장하고 보고서로 만드는 책임을 모두 가지고 있습니다. 보고서 형식이 변경되어도, DB 스키마가 변경되어도 이 클래스는 수정되어야 합니다.

좋은 예시 (SRP 적용):

// 1. 순수하게 직원 데이터만 책임지는 클래스
class Employee {
    public String name;
    public double salary;
}

// 2. DB 저장만 책임지는 클래스
class EmployeeRepository {
    public void save(Employee employee) {
        // 데이터베이스 저장 로직
    }
}

// 3. 보고서 생성만 책임지는 클래스
class EmployeeReportGenerator {
    public String generate(Employee employee) {
        // 보고서 생성 로직
    }
}

각 클래스가 하나의 책임만 가지도록 분리했습니다. 이제 보고서 형식을 바꾸려면 EmployeeReportGenerator만 수정하면 됩니다.

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

“소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다.”

새로운 기능이 추가되거나 기존 기능이 변경될 때, 이미 존재하는 코드를 수정하기보다는 새로운 코드를 추가하여 기능을 확장해야 한다는 원칙입니다. 이는 기존 코드의 안정성을 해치지 않으면서 시스템을 유연하게 확장할 수 있게 해줍니다. OCP는 주로 추상화(인터페이스, 추상 클래스)를 통해 달성됩니다.

나쁜 예시 (기능 추가 시 기존 코드 수정 필요):

class PaymentProcessor {
    public void Process(string paymentType) {
        if (paymentType == "CreditCard") {
            // 신용카드 결제 로직
        } else if (paymentType == "PayPal") {
            // 페이팔 결제 로직
        }
        // 새로운 결제 수단(ex: ApplePay)을 추가하려면 이 코드를 수정해야 함
    }
}

좋은 예시 (OCP 적용):

// 결제 수단에 대한 인터페이스(추상화)
public interface IPaymentMethod {
    void Pay();
}

// 구체적인 결제 수단 클래스들
public class CreditCard : IPaymentMethod {
    public void Pay() { /* 신용카드 결제 로직 */ }
}

public class PayPal : IPaymentMethod {
    public void Pay() { /* 페이팔 결제 로직 */ }
}

// 새로운 결제 수단 추가 (기존 코드 수정 불필요)
public class ApplePay : IPaymentMethod {
    public void Pay() { /* 애플페이 결제 로직 */ }
}

// PaymentProcessor는 추상화에만 의존
class PaymentProcessor {
    public void Process(IPaymentMethod paymentMethod) {
        paymentMethod.Pay();
    }
}

새로운 결제 수단인 ApplePay를 추가할 때, 기존의 어떤 코드도 수정할 필요 없이 IPaymentMethod를 구현한 새 클래스를 만들기만 하면 됩니다. 이것이 바로 ‘확장에는 열려 있고, 수정에는 닫혀 있는’ 상태입니다.

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

“상위 타입의 객체를 하위 타입의 객체로 치환해도 프로그램의 동작에 문제가 없어야 한다.”

이 원칙은 상속 관계의 올바른 사용에 대한 지침입니다. 자식 클래스는 부모 클래스의 역할을 완벽하게 수행할 수 있어야 하며, 부모 클래스에서 기대했던 동작을 변경하거나 무시해서는 안 됩니다. LSP를 위반하면 상속 구조 전체의 신뢰성이 무너집니다.

LSP 위반의 고전적인 예시: ‘정사각형/직사각형 문제’

Square(정사각형)는 Rectangle(직사각형)의 한 종류이므로(is-a 관계) 상속을 사용하는 것이 자연스러워 보입니다.

class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) { this.width = width; }
    public void setHeight(int height) { this.height = height; }
    public int getArea() { return width * height; }
}

class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width; // 정사각형은 너비와 높이가 같아야 함
    }

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

이제 Rectangle 타입을 기대하는 클라이언트 코드를 봅시다.

public void clientCode(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    // 클라이언트는 넓이가 5 * 4 = 20이 될 것이라 기대함
    assert r.getArea() == 20; 
}

만약 clientCodenew Square() 객체를 전달하면 어떻게 될까요? r.setHeight(4)가 호출되는 순간, Square 클래스의 재정의된 메소드에 의해 너비(width)까지 4로 변경됩니다. 따라서 넓이는 16이 되어 클라이언트의 기대(assert)를 깨트립니다. 이는 SquareRectangle을 완벽히 대체할 수 없다는 의미이며, LSP 위반입니다.

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

“클라이언트는 자신이 사용하지 않는 메서드에 의존하도록 강요받아서는 안 된다.”

SRP가 클래스의 책임을 분리하는 원칙이라면, ISP는 인터페이스의 책임을 분리하는 원칙입니다. 하나의 거대한 ‘만능’ 인터페이스보다는, 특정 클라이언트에 맞춰 여러 개의 작은 인터페이스로 분리하는 것이 좋습니다. 이를 통해 클라이언트는 자신에게 필요한 기능만 알고 의존하게 되어 시스템의 결합도를 낮출 수 있습니다.

나쁜 예시 (‘뚱뚱한’ 인터페이스):

interface IWorker {
    void work();
    void eat();
}

class HumanWorker implements IWorker {
    public void work() { /* 일하기 */ }
    public void eat() { /* 밥 먹기 */ }
}

class RobotWorker implements IWorker {
    public void work() { /* 일하기 */ }
    public void eat() {
        // 로봇은 밥을 먹지 않음. 이 메서드는 불필요함.
        // 어쩔 수 없이 비워두거나 예외를 던져야 함.
        throw new UnsupportedOperationException();
    }
}

좋은 예시 (ISP 적용):

interface IWorkable {
    void work();
}

interface IEatable {
    void eat();
}

class HumanWorker implements IWorkable, IEatable {
    public void work() { /* 일하기 */ }
    public void eat() { /* 밥 먹기 */ }
}

class RobotWorker implements IWorkable {
    public void work() { /* 일하기 */ }
}

인터페이스를 역할에 맞게 분리하여 RobotWorker는 더 이상 불필요한 eat 메서드에 의존하지 않게 되었습니다.

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

A. 상위 수준 모듈은 하위 수준 모듈에 의존해서는 안 된다. 둘 모두 추상화에 의존해야 한다. B. 추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.

이 원칙은 ‘의존 관계를 맺을 때, 변화하기 쉬운 구체적인 것(구현 클래스)보다는 변화하기 어려운 추상적인 것(인터페이스, 추상 클래스)에 의존하라’는 의미입니다.

일반적으로 상위 수준 모듈(비즈니스 로직)이 하위 수준 모듈(데이터베이스, 외부 API 연동)을 호출하는 구조에서, 상위 모듈이 하위 모듈을 직접 참조하면 하위 모듈이 변경될 때마다 상위 모듈도 변경되어야 하는 경직된 구조가 됩니다.

DIP는 이 의존성의 방향을 ‘역전’시킵니다. 중간에 추상적인 인터페이스를 두고, 상위 모듈과 하위 모듈 모두 이 인터페이스에 의존하게 만드는 것입니다. 이를 통해 둘 사이의 결합을 끊어내고 유연한 시스템을 만들 수 있습니다. 이 원칙을 구현하는 대표적인 기술이 바로 의존성 주입(Dependency Injection, DI) 입니다.

나쁜 예시 (상위 모듈이 하위 모듈에 직접 의존):

// 하위 수준 모듈
class EmailNotifier {
    public void sendEmail(String message) { /* 이메일 발송 로직 */ }
}

// 상위 수준 모듈
class OrderProcessor {
    private EmailNotifier notifier = new EmailNotifier(); // 직접 생성 및 의존

    public void process() {
        // 주문 처리 로직 ...
        notifier.sendEmail("주문이 완료되었습니다.");
    }
}

OrderProcessorEmailNotifier에 강하게 결합되어 있습니다. 만약 알림 방식을 이메일에서 SMS로 바꾸려면 OrderProcessor 코드를 직접 수정해야 합니다.

좋은 예시 (DIP 적용):

// 추상화
interface INotifier {
    void send(String message);
}

// 하위 수준 모듈들이 추상화에 의존
class EmailNotifier implements INotifier {
    public void send(String message) { /* 이메일 발송 로직 */ }
}

class SmsNotifier implements INotifier {
    public void send(String message) { /* SMS 발송 로직 */ }
}

// 상위 수준 모듈이 추상화에 의존
class OrderProcessor {
    private final INotifier notifier;

    // 외부에서 의존성을 주입받음 (DI)
    public OrderProcessor(INotifier notifier) {
        this.notifier = notifier;
    }

    public void process() {
        // 주문 처리 로직 ...
        notifier.send("주문이 완료되었습니다.");
    }
}

// 사용 예시
INotifier emailNotifier = new EmailNotifier();
OrderProcessor orderProcessor1 = new OrderProcessor(emailNotifier);

INotifier smsNotifier = new SmsNotifier();
OrderProcessor orderProcessor2 = new OrderProcessor(smsNotifier);

OrderProcessor는 이제 구체적인 알림 방식(EmailNotifier, SmsNotifier)을 전혀 모릅니다. 오직 INotifier 인터페이스만 알 뿐입니다. 덕분에 알림 방식을 자유롭게 교체할 수 있는 유연한 구조가 되었습니다.

4. 더 나은 설계를 위한 심화 원칙

SOLID 원칙 외에도 코드의 품질을 높여주는 여러 중요한 원칙들이 있습니다.

원칙 (Principle)핵심 아이디어
응집도와 결합도 (Cohesion & Coupling)좋은 설계는 높은 응집도(High Cohesion)낮은 결합도(Low Coupling) 를 지향한다.
데메테르 법칙 (Law of Demeter)“낯선 이에게 말하지 마라.” 객체는 자신의 직접적인 친구하고만 소통해야 한다. (a.getB().getC().doSomething() 같은 코드를 피하라)
관심사 분리 (Separation of Concerns, SoC)프로그램을 각기 다른 관심사를 다루는 별개의 부분으로 분리한다. (e.g., MVC 패턴)
명령-조회 분리 (Command-Query Separation, CQS)메서드는 상태를 변경하는 ‘명령(Command)‘이거나, 데이터를 반환하는 ‘조회(Query)‘여야 하며, 둘 다여서는 안 된다.

특히 응집도결합도는 소프트웨어 설계의 품질을 측정하는 가장 근본적인 척도입니다. 위에서 소개한 모든 원칙들은 결국 모듈 내의 요소들이 얼마나 밀접하게 관련 있는지를 나타내는 ‘응집도’는 높이고, 모듈과 모듈 사이의 의존성 정도를 나타내는 ‘결합도’는 낮추기 위한 구체적인 전략이라고 할 수 있습니다.

핸드북을 마치며

지금까지 우리는 견고하고 유연한 소프트웨어를 만들기 위한 핵심적인 디자인 원칙들을 살펴보았습니다. 이 원칙들은 하루아침에 체득되는 것이 아닙니다. 꾸준히 코드를 작성하고, 자신의 코드를 비판적으로 바라보며 리팩토링하는 과정을 통해 자연스럽게 녹아들게 됩니다.

디자인 원칙을 맹목적으로 따르는 것이 항상 정답은 아닐 수 있습니다. 때로는 프로젝트의 상황과 제약 조건에 따라 실용적인 절충안을 선택해야 할 때도 있습니다. 중요한 것은 이 원칙들이 추구하는 ‘왜’ 를 이해하고, 모든 코드 작성의 순간에 스스로에게 “더 나은 방법은 없을까?” 라고 질문하는 습관을 들이는 것입니다.

이 핸드북이 여러분의 코드에 깊이를 더하고, 장인 정신을 가진 개발자로 성장해 나가는 여정에 든든한 동반자가 되기를 바랍니다.

레퍼런스(References)

디자인 원칙