들어가며: 좋은 코드란 무엇일까?
우리는 종종 “좋은 코드”에 대해 이야기합니다. 단순히 동작하는 코드를 넘어, 시간이 지나도 이해하기 쉽고, 새로운 기능을 추가하기 용이하며, 예기치 않은 버그가 적은 코드를 우리는 좋은 코드라고 부릅니다. 그렇다면 어떻게 그런 코드를 작성할 수 있을까요?
그 길잡이가 되어주는 것이 바로 SOLID라는 다섯 가지 객체 지향 설계 원칙입니다. SOLID 원칙을 따르는 코드는 결합도(Coupling)는 낮추고 응집도(Cohesion)는 높여, 변화에 유연하고 유지보수가 쉬운 구조를 갖게 됩니다.
이번 글에서는 Go의 간결한 문법, 특히 인터페이스와 구조체를 활용하여 SOLID 원칙을 어떻게 적용할 수 있는지, 실제 예제와 함께 하나씩 살펴보겠습니다.
1. 단일 책임 원칙 (Single Responsibility Principle, SRP)
“하나의 클래스(구조체)는 단 하나의 책임만 가져야 한다.”
가장 기본적이면서도 중요한 원칙입니다. 여기서 ‘책임’의 변화는 곧 ‘수정의 이유’가 됩니다. 만약 하나의 구조체가 여러 책임을 가지고 있다면, 한 책임의 변경이 다른 책임에 의도치 않은 영향을 미칠 수 있어 코드 관리가 어려워집니다.
예시: 보고서 생성과 전송
회계 보고서를 생성하고, 그 내용을 이메일로 전송하는 기능이 하나의 구조체에 모두 포함된 경우를 생각해 봅시다.
나쁜 설계: 모든 것을 다 하려는 구조체
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
package main import "fmt" // 회계 보고서 생성과 전송 책임을 모두 가짐 type FinanceReport struct { reportData string } func (r *FinanceReport) GenerateReport() { r.reportData = "This is a finance report." fmt.Println("Generating report...") } // 보고서 전송 기능까지 포함 func (r *FinanceReport) SendReport(email string) { fmt.Printf("Sending report '%s' via email to: %s\n", r.reportData, email) } func main() { report := FinanceReport{} report.GenerateReport() report.SendReport("ceo@example.com") }
위 코드는 보고서 내용이 바뀌어도, 전송 방식(예: 이메일 -> SMS)이 바뀌어도
FinanceReport
구조체를 수정해야 합니다. 즉, 두 가지 수정의 이유를 가집니다.좋은 설계: 책임을 명확히 분리
SRP를 적용하여 ‘보고서 생성’과 ‘전송’의 책임을 분리해 보겠습니다.
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
package main import "fmt" // Report라는 데이터 구조 정의 type Report struct { Data string } // 보고서 생성을 책임지는 구조체 type ReportGenerator struct{} func (g *ReportGenerator) Generate(data string) Report { fmt.Println("Generating report...") return Report{Data: data} } // 보고서 전송을 책임지는 구조체 type ReportSender struct{} func (s *ReportSender) Send(report Report, email string) { fmt.Printf("Sending report '%s' via email to: %s\n", report.Data, email) } func main() { generator := ReportGenerator{} report := generator.Generate("This is a finance report.") sender := ReportSender{} sender.Send(report, "ceo@example.com") }
이제 보고서 생성 로직은
ReportGenerator
가, 전송 로직은ReportSender
가 각각 책임집니다. 전송 방식에 새로운 기능(SMS, FTP 등)이 추가되어도ReportGenerator
나Report
구조체는 전혀 영향을 받지 않습니다.
2. 개방-폐쇄 원칙 (Open-Closed Principle, OCP)
“소프트웨어 요소(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하지만, 수정에 대해서는 닫혀 있어야 한다.”
새로운 기능을 추가할 때 기존 코드를 변경하는 것이 아니라, 새로운 코드를 추가함으로써 기능을 확장해야 한다는 원칙입니다. 이는 Go의 인터페이스를 통해 우아하게 구현할 수 있습니다.
예시: 다양한 결제 방법 추가
결제 시스템에서 신용카드 결제만 지원하다가, 카카오페이, 네이버페이 등 새로운 결제 수단을 추가해야 하는 상황을 가정해 봅시다.
나쁜 설계: 새로운 기능마다 분기문 추가
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
package main import "fmt" type PaymentProcessor struct{} // 새로운 결제 수단이 추가될 때마다 이 함수를 수정해야 함 func (p *PaymentProcessor) Process(method string, amount float64) { switch method { case "CreditCard": fmt.Printf("Processing %.2f via Credit Card\n", amount) case "KakaoPay": fmt.Printf("Processing %.2f via KakaoPay\n", amount) // case "NaverPay": 추가 시 또 코드 변경 필요 // ... } }
좋은 설계: 인터페이스를 통한 확장
PayMethod
라는 인터페이스를 정의하고, 각 결제 수단이 이 인터페이스를 구현하도록 설계합니다.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
package main import "fmt" // PayMethod 인터페이스 정의 type PayMethod interface { Pay(amount float64) } // 신용카드 결제 type CreditCard struct{} func (c CreditCard) Pay(amount float64) { fmt.Printf("Processing %.2f via Credit Card\n", amount) } // 카카오페이 결제 type KakaoPay struct{} func (k KakaoPay) Pay(amount float64) { fmt.Printf("Processing %.2f via KakaoPay\n", amount) } // 네이버페이라는 새로운 기능이 추가되어도 기존 코드는 수정할 필요가 없음 type NaverPay struct{} func (n NaverPay) Pay(amount float64) { fmt.Printf("Processing %.2f via NaverPay\n", amount) } // 결제 처리기는 PayMethod 인터페이스에만 의존 type PaymentProcessor struct{} func (p *PaymentProcessor) Process(method PayMethod, amount float64) { method.Pay(amount) } func main() { processor := &PaymentProcessor{} amount := 100.50 processor.Process(CreditCard{}, amount) processor.Process(KakaoPay{}, amount) processor.Process(NaverPay{}, amount) // 새로운 결제 수단도 문제 없이 처리 }
이제
NaverPay
나 다른 결제 수단을 추가하고 싶을 때,PaymentProcessor
의 코드는 단 한 줄도 수정할 필요가 없습니다. 새로운 구조체를 만들어PayMethod
인터페이스를 구현하기만 하면 되므로, 시스템은 확장에는 열려있고, 수정에는 닫혀있게 됩니다.
3. 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)
“서브 타입(자식 클래스)은 언제나 기반 타입(부모 클래스)으로 교체할 수 있어야 한다.”
인터페이스를 구현한 구조체는 해당 인터페이스의 역할을 완벽하게 수행해야 한다는 의미입니다. 즉, 특정 인터페이스를 사용하는 코드에 그 인터페이스를 구현한 어떤 구조체를 전달하더라도, 프로그램의 동작이 예상대로 흘러가야 합니다.
예시: 직사각형과 정사각형
고전적인 예시인 ‘직사각형’과 ‘정사각형’의 관계입니다. 수학적으로 정사각형은 직사각형의 특별한 경우이지만, 프로그래밍에서는 동작 방식의 차이로 인해 문제가 발생할 수 있습니다.
나쁜 설계: 잘못된 추상화
정사각형의 너비(Width)나 높이(Height)를 개별적으로 바꾸려는 시도는 정사각형의 정의(“모든 변의 길이는 같다”)를 위반합니다.
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
package main import "fmt" type Rectangle struct { width, height int } // 너비와 높이를 설정하는 함수 func UseIt(rect *Rectangle) { rect.height = 10 // 높이를 10으로 설정 fmt.Printf("Expected area: %d, Got: %d\n", 50, rect.width*rect.height) } type Square struct { Rectangle // 상속(임베딩) } // 정사각형을 만드는 생성자 func NewSquare(size int) *Square { sq := &Square{} sq.width = size sq.height = size return sq } func main() { rect := &Rectangle{width: 5, height: 5} UseIt(rect) // Expected area: 50, Got: 50 -> 정상 동작 sq := NewSquare(5) // Square를 Rectangle로 치환하여 전달했지만, // UseIt 함수는 Square의 성질(너비와 높이가 같아야 함)을 깨뜨림 UseIt(&sq.Rectangle) // Expected area: 50, Got: 50 -> 이 시점까지는 문제가 없어 보이지만... // sq의 상태는 이제 width: 5, height: 10 으로 정사각형이 아님! fmt.Printf("Final square dimensions: %d x %d\n", sq.width, sq.height) }
Square
를UseIt
함수에 전달하자,Rectangle
을 기대했던 함수의 동작이Square
의 규칙을 깨뜨렸습니다. 이는 리스코프 치환 원칙을 위반한 것입니다.좋은 설계: 상속 대신 공통 인터페이스 사용
잘못된 상속 관계를 사용하는 대신, 공통의 동작(예: 면적 계산)을 인터페이스로 추출합니다.
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
package main import "fmt" // 도형(Shape)이라면 면적(Area)을 구하는 기능이 있어야 함 type Shape interface { Area() int } type Rectangle struct { width, height int } func (r Rectangle) Area() int { return r.width * r.height } type Square struct { side int } func (s Square) Area() int { return s.side * s.side } func main() { // 공통 인터페이스 Shape를 통해 각 도형을 다룸 shapes := []Shape{ Rectangle{width: 10, height: 5}, Square{side: 7}, } for _, shape := range shapes { // 각 도형은 Shape 인터페이스를 완벽히 만족하며, Area() 메서드는 예상대로 동작 fmt.Printf("Area is: %d\n", shape.Area()) } }
이제 각 도형은
Shape
인터페이스를 통해 일관되게 다룰 수 있으며, 서로의 구현 세부사항에 영향을 주지 않아 LSP를 준수하게 됩니다.
4. 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)
“클라이언트는 자신이 사용하지 않는 메서드에 의존하도록 강요받아서는 안 된다.”
“뚱뚱한” 인터페이스 하나를 만드는 대신, 기능별로 잘게 쪼개진 여러 개의 “작은” 인터페이스를 만들라는 원칙입니다. 클라이언트는 자신이 필요한 기능에 해당하는 인터페이스만 구현하면 됩니다.
예시: 프린터와 복합기
인쇄, 스캔, 팩스 기능이 모두 포함된 MultiFunctionDevice
인터페이스가 있다고 상상해 봅시다. 만약 ‘인쇄’만 가능한 단순한 프린터가 이 인터페이스를 구현하려면, 사용하지도 않는 Scan
과 Fax
메서드까지 억지로 구현해야 합니다.
좋은 설계: 기능별 인터페이스 분리
각 기능을 별도의 인터페이스로 분리합니다.
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
package main import "fmt" // 각 기능을 작은 인터페이스로 분리 type Printer interface { Print(content string) } type Scanner interface { Scan() string } // 복합기는 두 인터페이스를 모두 구현 type MultiFunctionPrinter struct{} func (p MultiFunctionPrinter) Print(content string) { fmt.Println("Printing content:", content) } func (p MultiFunctionPrinter) Scan() string { scannedContent := "This is a scanned document." fmt.Println("Scanning...") return scannedContent } // 단순 프린터는 Printer 인터페이스만 구현 type SimplePrinter struct{} func (p SimplePrinter) Print(content string) { fmt.Println("Simple printer is printing:", content) } func main() { // 복합기는 인쇄기와 스캐너로 모두 사용 가능 mfp := MultiFunctionPrinter{} var p1 Printer = mfp var s1 Scanner = mfp p1.Print("Hello") s1.Scan() // 단순 프린터는 인쇄기로만 사용 가능 sp := SimplePrinter{} var p2 Printer = sp p2.Print("World") // 컴파일 에러: SimplePrinter는 Scanner 인터페이스를 구현하지 않았음 // var s2 Scanner = sp }
이제
SimplePrinter
는 자신이 필요한Printer
인터페이스만 구현하면 되므로, 불필요한 기능에 대한 의존성이 사라졌습니다.
5. 의존 역전 원칙 (Dependency Inversion Principle, DIP)
“고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화(인터페이스)에 의존해야 한다.”
쉽게 말해, 세부적인 구현(저수준 모듈)이 변경되더라도, 전체적인 비즈니스 로직(고수준 모듈)은 영향을 받지 않아야 한다는 원칙입니다. 이는 인터페이스를 중간에 두어 의존성의 방향을 “역전”시킴으로써 달성됩니다.
예시: 알림 서비스
사용자에게 이메일로 알림을 보내는 NotificationService
가 있다고 가정해 봅시다.
나쁜 설계: 구체적인 구현에 직접 의존
NotificationService
가 구체적인EmailSender
구조체에 직접 의존합니다.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
package main import "fmt" // 저수준 모듈: 이메일 발송기 type EmailSender struct{} func (s *EmailSender) SendEmail(message string) { fmt.Println("Sending email:", message) } // 고수준 모듈: 알림 서비스 type NotificationService struct { // 구체적인 EmailSender에 직접 의존! emailSender *EmailSender } func (s *NotificationService) Notify(message string) { s.emailSender.SendEmail(message) } func main() { // 만약 SMS 알림을 추가하려면 NotificationService 코드를 변경해야만 함 service := &NotificationService{emailSender: &EmailSender{}} service.Notify("Hello via Email") }
이 설계에서는 알림 방식이 이메일에서 SMS나 푸시 알림으로 바뀌면, 고수준 모듈인
NotificationService
의 코드를 직접 수정해야만 합니다.좋은 설계: 인터페이스에 의존
Notifier
라는 추상적인 인터페이스를 만들고, 고수준 모듈과 저수준 모듈이 모두 이 인터페이스에 의존하도록 설계합니다.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
package main import "fmt" // 추상화: Notifier 인터페이스 type Notifier interface { Send(message string) } // 저수준 모듈 1: 이메일 발송기 type EmailSender struct{} func (s EmailSender) Send(message string) { fmt.Println("Sending email:", message) } // 저수준 모듈 2: SMS 발송기 type SmsSender struct{} func (s SmsSender) Send(message string) { fmt.Println("Sending SMS:", message) } // 고수준 모듈: 알림 서비스 type NotificationService struct { // 추상적인 Notifier 인터페이스에 의존! notifier Notifier } func (s *NotificationService) Notify(message string) { s.notifier.Send(message) } func main() { // 이메일로 알림 보내기 emailService := &NotificationService{notifier: EmailSender{}} emailService.Notify("Hello via Email") // SMS로 알림 보내기 // NotificationService의 코드는 전혀 변경되지 않음! smsService := &NotificationService{notifier: SmsSender{}} smsService.Notify("Hello via SMS") }
이제
NotificationService
는EmailSender
나SmsSender
의 존재를 전혀 모릅니다. 오직Notifier
인터페이스의Send
메서드를 호출할 뿐입니다. 이처럼 의존성의 방향을 구체적인 것에서 추상적인 것으로 “역전”시킴으로써, 시스템은 유연하고 확장 가능하며 테스트하기 쉬운 구조를 갖게 됩니다.
마무리하며
SOLID 원칙은 당장 코드를 작성하는 데 약간의 시간이 더 걸리는 것처럼 보일 수 있습니다. 하지만 장기적인 관점에서 보면, 이 원칙들은 미래의 ‘나’와 동료들을 위한 최고의 투자입니다.
Go의 강력한 인터페이스 시스템은 SOLID 원칙을 적용하기에 매우 훌륭한 환경을 제공합니다. 오늘 살펴본 다섯 가지 원칙을 의식하며 코드를 작성하는 습관을 들인다면, 분명 더 견고하고 유연하며 지속 가능한 소프트웨어를 만들어나갈 수 있을 것입니다.