디자인 패턴의 개념과 SOLID 원칙
소프트웨어 설계의 기본이 되는 디자인 패턴의 개념과 SOLID 5원칙을 실제 C# 코드 예제와 함께 알아봅니다.
디자인 패턴이란?
디자인 패턴은 소프트웨어 설계에서 반복적으로 발생하는 문제들을 해결하기 위한 검증된 해결책입니다.
특정 상황에서 코드를 어떻게 구성하고 상호 작용하도록 만들어야 하는지에 대한 모범 사례나 청사진과 같습니다. 디자인 패턴은 특정 프로그래밍 언어에 종속되지 않는 개념적인 해결책으로, 다양한 상황에 맞게 유연하게 적용될 수 있습니다.
쉽게 말해, 경험 많은 개발자들이 특정 유형의 문제를 해결하면서 “이런 상황에서는 이렇게 코드를 짜는 것이 가장 효율적이고 유지보수하기 좋더라”라고 정리해 놓은 일종의 ‘족보’ 나 ‘레시피’ 라고 생각할 수 있습니다.
디자인 패턴의 장점
- 개발자 간 소통의 공통 언어 제공
- 검증된 해결책으로 안정성 확보
- 코드 재사용성과 유지보수성 향상
- 복잡한 설계 문제의 체계적 해결
SOLID: 객체 지향 설계의 5가지 원칙
SOLID란 객체 지향 프로그래밍 및 설계에서 제시된 다섯 가지 기본 원칙을 의미합니다. 이 원칙들을 따르면 소프트웨어의 이해도, 유연성, 유지보수성을 크게 높일 수 있습니다.
이러한 객체 지향 설계의 5가지 원칙은 디자인 패턴의 기반이 되며, 좋은 소프트웨어 아키텍처를 구축하는 핵심 가이드라인입니다.
원칙 | 영문명 | 핵심 개념 |
---|---|---|
S | Single Responsibility Principle | 단일 책임 |
O | Open/Closed Principle | 확장에 열려있고 수정에 닫혀있음 |
L | Liskov Substitution Principle | 서브타입 치환 가능성 |
I | Interface Segregation Principle | 인터페이스 분리 |
D | Dependency Inversion Principle | 의존성 역전 |
1. 단일 책임 원칙 (Single Responsibility Principle - SRP)
“한 클래스는 하나의 책임만 가져야 한다.”
클래스를 변경해야 하는 이유는 단 하나여야 한다는 의미입니다. 클래스가 여러 책임을 가지게 되면, 한 책임의 변경이 다른 책임에 영향을 미칠 수 있으므로 코드의 복잡성과 취약성이 증가합니다.
🔍 실무 예시
- ❌ 잘못된 예:
User
클래스가 사용자 정보 관리 + 데이터베이스 저장 + 이메일 발송을 모두 담당 - ✅ 올바른 예:
User
(사용자 정보),UserRepository
(데이터 저장),EmailService
(이메일 발송)으로 분리
💡 코드 예제
아래 예제는 Journal
클래스와 Persistence
클래스를 분리하여 SRP를 준수하는 사례입니다. Journal
클래스는 일지 항목 관리 책임을, Persistence
클래스는 일지 저장 책임을 각각 담당합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// SRP.cs - 단일 책임 원칙 준수 예제
using System;
using System.Collections.Generic;
using System.IO;
namespace DesignPatterns
{
// Journal 클래스: 일지 항목 관리만 담당 (단일 책임)
public class Journal
{
private readonly List<string> entries = new List<string>();
private static int count = 0;
// 항목 추가 - 핵심 책임
public int AddEntry(string text)
{
entries.Add($"{++count}: {text}");
return count; // momento 패턴을 위한 반환값
}
// 항목 제거 - 핵심 책임
public void RemoveEntry(int index)
{
entries.RemoveAt(index);
}
public override string ToString()
{
return string.Join(Environment.NewLine, entries);
}
}
// Persistence 클래스: 파일 저장/로드만 담당 (단일 책임)
public class Persistence
{
public void SaveToFile(Journal journal, string filename, bool overwrite = false)
{
if (overwrite || !File.Exists(filename))
{
File.WriteAllText(filename, journal.ToString());
}
}
public void Load(Uri uri)
{
// 파일 로드 로직
}
}
// 사용법
class Program
{
static void Main()
{
var journal = new Journal();
journal.AddEntry("Today I learned about SRP");
journal.AddEntry("SRP makes code more maintainable");
var persistence = new Persistence();
persistence.SaveToFile(journal, "diary.txt");
}
}
}
🎯 SRP의 장점
- 유지보수성: 각 클래스의 목적이 명확해 수정이 용이
- 테스트 용이성: 단일 기능에 대한 테스트 작성이 간단
- 재사용성: 독립적인 기능으로 다른 곳에서 재사용 가능
2. 개방-폐쇄 원칙 (Open/Closed Principle - OCP)
“소프트웨어 요소(클래스, 모듈, 함수 등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다.”
이는 기존 코드를 변경하지 않으면서 기능을 추가할 수 있어야 함을 의미한다. 이는 주로 인터페이스와 추상 클래스를 통해 구현된다.
OCP.cs
예제는 ISpecification
과 IFilter
인터페이스를 사용함으로써, 새로운 필터링 조건 추가 시 기존 ProductFilter
클래스를 수정하지 않고 기능을 확장하는 방법을 보여준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// OCP.cs
using static System.Console;
namespace DignPatterns
{
public enum Color
{
Red, Green, Blue
}
public enum Size
{
Small, Medium, Large, Yuge
}
public class Product
{
public string Name;
public Color Color;
public Size Size;
public Product(string name, Color color, Size size)
{
if (name == null)
{
throw new ArgumentNullException(paramName: nameof(name));
}
Name = name;
Color = color;
Size = size;
}
}
public interface ISpecification<T> {
bool IsSatisfied(T t);
}
public interface IFilter<T> {
IEnumerable<T> Filter(IEnumerable<T> item, ISpecification<T> spec);
}
public class ColorSpecification : ISpecification<Product>
{
private Color color;
public ColorSpecification(Color color)
{
this.color = color;
}
public bool IsSatisfied(Product t)
{
return t.Color == color;
}
}
public class SizeSpecification : ISpecification<Product>
{
Size size;
public SizeSpecification(Size size)
{
this.size = size;
}
public bool IsSatisfied(Product t)
{
return t.Size == size;
}
}
public class AndSpecification<T> : ISpecification<T>
{
private ISpecification<T> first, second;
public AndSpecification(ISpecification<T> first, ISpecification<T> second)
{
this.first = first ?? throw new ArgumentNullException(nameof(first));
this.second = second ?? throw new ArgumentNullException(nameof(second));
}
public bool IsSatisfied(T t)
{
return first.IsSatisfied(t) && second.IsSatisfied(t);
}
}
}
3. 리스코프 치환 원칙 (Liskov Substitution Principle - LSP)
“서브타입은 언제나 기반 타입으로 교체할 수 있어야 한다.”
자식 클래스는 부모 클래스가 할 수 있는 행위를 동일하게 수행할 수 있어야 합니다. 즉, 자식 클래스는 부모 클래스의 계약(메서드 시그니처, 예외 처리 등)을 위반해서는 안 됩니다.
🚨 LSP 위반 예시
아래 예제에서 Square
클래스는 Rectangle
클래스를 상속하지만, Width
또는 Height
속성 설정 시 다른 속성값도 함께 변경되므로 Rectangle
의 일반적인 동작 방식과 차이가 발생합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// LSP.cs - LSP 위반 가능성을 보여주는 예제
using static System.Console;
namespace DesignPatterns
{
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public Rectangle(int height, int width)
{
Height = height;
Width = width;
}
public Rectangle() { }
public override string ToString()
{
return $"{nameof(Width)}: {Width}, {nameof(Height)}: {Height}";
}
}
// 문제가 되는 Square 클래스
public class Square : Rectangle
{
public override int Width
{
set { base.Width = base.Height = value; } // 두 값이 동시에 변경됨
}
public override int Height
{
set { base.Width = base.Height = value; } // 두 값이 동시에 변경됨
}
}
}
4. 인터페이스 분리 원칙 (Interface Segregation Principle - ISP)
“클라이언트는 자신이 사용하지 않는 메서드에 의존하도록 강요되어서는 안 된다.”
하나의 거대한 인터페이스보다는, 특정 클라이언트를 위한 여러 개의 작은 인터페이스로 분리하는 것이 더 효율적이다.
ISP.cs
예제는 IMachine
이라는 단일 인터페이스 대신, IPrinter
, IScanner
와 같이 더 작고 구체적인 인터페이스로 분리하는 것을 보여준다. 이를 통해 OldFashionedPrinter
처럼 모든 기능을 구현할 필요가 없는 클래스가 불필요한 메서드까지 구현해야 하는 상황을 방지할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// ISP.cs
using static System.Console;
namespace DesignPattern
{
public class Document
{
}
public interface IPrinter
{
void Print(Document d);
}
public interface IScanner
{
void Scan(Document d);
}
public class Photocopier : IPrinter, IScanner
{
public void Print(Document d)
{
//
}
public void Scan(Document d)
{
//
}
}
public interface IMultiFunctionDevice : IScanner, IPrinter
{
}
public class MultiFunctionMachine : IMultiFunctionDevice
{
private IPrinter printer;
private IScanner scanner;
public MultiFunctionMachine(IPrinter printer, IScanner scanner)
{
this.printer = printer ?? throw new ArgumentNullException(nameof(printer));
this.scanner = scanner ?? throw new ArgumentNullException(nameof(scanner));
}
public void Print(Document d)
{
printer.Print(d);
}
public void Scan(Document d)
{
scanner.Scan(d);
}
}
}
5. 의존성 역전 원칙 (Dependency Inversion Principle - DIP)
“상위 수준 모듈은 하위 수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다.” “추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.”
고수준 모듈이 저수준 모듈의 구체적인 구현에 직접 의존하는 것이 아니라, 양쪽 모두 추상화(예: 인터페이스)에 의존해야 한다는 원칙이다.
DIP.cs
예제에서 Research
모듈(고수준)은 Relationships
클래스(저수준)가 아닌, IRelationshipBrowser
인터페이스에 의존한다. 이를 통해 Research
모듈은 Relationships
의 내부 구현이 변경되더라도 그 영향에서 벗어날 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// DIP.cs - 의존성 역전 원칙 준수 예제
using static System.Console;
using System.Linq;
namespace DesignPatterns
{
public enum Relationship
{
Parent,
Child,
Sibling
}
public class Person
{
public string name;
}
// 추상화: 고수준 모듈이 의존할 인터페이스
public interface IRelationshipBrowser
{
IEnumerable<Person> FindAllChildrenOf(string name);
}
// 저수준 모듈: 구체적인 구현
public class Relationships : IRelationshipBrowser
{
private List<(Person, Relationship, Person)> relations
= new List<(Person, Relationship, Person)>();
public void AddParentAndChild(Person parent, Person child)
{
relations.Add((parent, Relationship.Parent, child));
relations.Add((child, Relationship.Child, parent));
}
// 인터페이스 구현: 내부 구조를 숨기고 필요한 기능만 제공
public IEnumerable<Person> FindAllChildrenOf(string name)
{
return relations.Where(
x => x.Item1.name == name &&
x.Item2 == Relationship.Parent
).Select(r => r.Item3);
}
}
// 고수준 모듈: 추상화에만 의존
public class Research
{
public Research(IRelationshipBrowser browser)
{
foreach (var p in browser.FindAllChildrenOf("John"))
{
WriteLine($"John has a child called {p.name}");
}
}
}
// 사용 예시
class Program
{
static void Main()
{
var parent = new Person { name = "John" };
var child1 = new Person { name = "Chris" };
var child2 = new Person { name = "Mary" };
var relationships = new Relationships();
relationships.AddParentAndChild(parent, child1);
relationships.AddParentAndChild(parent, child2);
// Research는 Relationships의 구체적인 구현을 알 필요 없이
// IRelationshipBrowser 인터페이스를 통해서만 상호작용
new Research(relationships);
}
}
}
🎯 DIP의 핵심 장점
- 유연성: 구현체를 쉽게 교체할 수 있음
- 테스트 용이성: Mock 객체를 사용한 단위 테스트 가능
- 결합도 감소: 고수준 모듈이 저수준 모듈의 변경에 영향받지 않음
📝 정리
SOLID 원칙은 좋은 객체 지향 설계의 기본이 되는 다섯 가지 원칙입니다:
원칙 | 핵심 | 효과 |
---|---|---|
SRP | 단일 책임 | 유지보수성 ↑ |
OCP | 확장 열림, 수정 닫힘 | 유연성 ↑ |
LSP | 서브타입 치환 가능 | 안정성 ↑ |
ISP | 인터페이스 분리 | 결합도 ↓ |
DIP | 추상화 의존 | 재사용성 ↑ |
이러한 원칙들을 이해하고 적용하면, 더 깔끔하고 유지보수하기 쉬운 코드를 작성할 수 있습니다. 다음 글에서는 이러한 원칙들을 바탕으로 한 구체적인 디자인 패턴들을 살펴보겠습니다.
다음 글 예고: 생성 패턴(Creational Patterns) - Singleton, Factory, Builder 패턴을 실제 예제와 함께 알아보겠습니다! 🚀