티스토리 뷰
📺 시리즈
2023.10.02 - [Go/디자인 패턴] - [Go] SOLID in Go - SOLID란?
2023.10.03 - [Go/디자인 패턴] - [Go] SOLID in Go - 구조체와 메서드
2023.10.04 - [Go/디자인 패턴] - [Go] SOLID in Go - 인터페이스
2023.10.09 - [Go/디자인 패턴] - [Go] SOLID in Go - 컴포지션
2023.10.10 - [Go/디자인 패턴] - [Go] SOLID in Go - 패키지
☝ 단일 책임 원칙 (Single Responsibility Principle)
모든 객체는 하나의 책임만을 져야 한다.
단일 책임 원칙은 객체가 단일 책임 또는 역할에 집중하도록 유도함으로써 소프트웨어를 더 쉽게 이해하고 유지보수할 수 있게 도와주며 코드의 재사용성을 높여줍니다.
언뜻 보면 굉장히 간단하고 당연해 보이지만, 실제로 코드를 작성하다 보면 무시되기 쉬운 원칙입니다. 현실에서도 포지션을 보고 들어가지만, 막상 일하다 보면 어디까지가 내 일인지 불분명해지는 상황과 비슷하죠.
단일 책임 원칙을 따르지 않는 코드
package srp
import (
"fmt"
"os"
"strings"
)
var ReportFormat = "%s\n%s"
// Report는 보고서를 생성하고 출력하고 저장하고 불러오는 역할을 함.
type Report struct {
Title string
Content string
}
// CreateReport는 보고서를 생성함.
func (r *Report) CreateReport(title, content string) {
r.Title = title
r.Content = content
}
// SaveToFile는 보고서를 파일에 저장함.
func (r *Report) SaveToFile(filename string) error {
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
fileContent := r.FormatReport()
_, err = file.WriteString(fileContent)
if err != nil {
return err
}
return nil
}
// LoadFromFile는 파일에서 보고서를 읽어옴.
func (r *Report) LoadFromFile(filename string) error {
file, err := os.Open(filename)
if err != nil {
return err
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return err
}
bs := make([]byte, stat.Size())
_, err = file.Read(bs)
if err != nil {
return err
}
fileContent := string(bs)
lines := strings.Split(fileContent, "\n")
if len(lines) != 2 {
return fmt.Errorf("invalid format")
}
title := lines[0]
content := lines[1]
r.CreateReport(title, content)
return nil
}
// FormatReport는 보고서를 형식에 맞는 문자열로 반환함.
func (r *Report) FormatReport() string {
return fmt.Sprintf(ReportFormat, r.Title, r.Content)
}
Report는 보고서 객체입니다. 보고서를 생성하는 메서드, 보고서를 파일에 저장하는 메서드, 보고서를 파일로부터 불러오는 메서드 그리고 보고서를 형식에 맞게 변환하는 메서드를 가지고 있습니다. 생성, 저장, 불러오기 그리고 변환까지 총 네 개의 책임을 지고 있는 것입니다. 이렇게 단일 객체가 혼자서 너무 많은 역할 또는 책임을 지고 있는 경우, 신과 같은 권능을 가지고 있다 하여 이를 신의 객체(God Object)라고 부릅니다.
이러한 객체는 혼자서 모든 것을 처리하려다 보니 코드를 복잡하게 만들고 예상치 못한 오류를 일으킬 수 있습니다. 예를 들어, 파일에서 보고서를 불러오는 메서드는 새로운 객체를 반환하지 않고 불러온 값으로 기존의 값을 덮어쓰게 되므로 다음과 같이 완전히 다른 값을 가지게 될 수 있습니다. 코드가 더 길고 복잡해질수록 이런 문제가 어디서 발생하는지 발견하기가 더 어려워질 것입니다.
package main
import (
"fmt"
"solid/srp"
)
func main() {
report := srp.Report{}
title := "초전도치의 비밀"
content := "초전도치는 비밀이다."
report.CreateReport(title, content)
if err := report.SaveToFile("report.txt"); err != nil {
panic(err)
}
if err := report.LoadFromFile("report1.txt"); err != nil {
panic(err)
}
fmt.Println(report.Title == title)
fmt.Println(report.Content == content)
}
$ go run main.go
false
false
단일 책임 원칙을 따르는 코드
앞서 단일 책임 원칙을 따르지 않는 코드를 수정하여 단일 책임 원칙을 적용해 보았습니다.
var ReportFormat = "%s\n%s"
// Report는 보고서의 내용을 담는 구조체
type Report struct {
Title string
Content string
}
// String은 보고서의 내용을 문자열로 반환함. (Stringer 인터페이스 구현)
func (r *Report) String() string {
return fmt.Sprintf(ReportFormat, r.Title, r.Content)
}
Report 구조체는 그대로지만, 이제 보고서를 형식에 맞게 문자열로 반환하는 String 메서드만을 가지고 있습니다.
// CreateReport는 새로운 보고서를 생성함.
func NewReport(title, content string) Report {
return Report{
Title: title,
Content: content,
}
}
CreateReport 메서드는 NewReport 생성자 함수로 분리하여 생성자를 호출하게 되면 새로운 Report 인스턴스를 반환하도록 변경하였습니다.
type ReportSaver struct{} // ReportSaver는 보고서를 파일에 저장하는 구조체
// SaveToFile는 보고서를 파일에 저장함.
func (rs *ReportSaver) SaveToFile(r Report, filename string) error {
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
fileContent := r.String()
_, err = file.WriteString(fileContent)
if err != nil {
return err
}
return nil
}
SaveToFile 메서드는 이제 새로운 ReportSaver 구조체의 메서드로써 매개변수로 Report 구조체와 파일이름을 받아 보고서를 저장하도록 수정하였습니다.
type ReportLoader struct{} // ReportLoader는 보고서를 파일에서 읽어오는 구조체
// LoadFromFile는 파일에서 보고서를 읽어옴.
func (rl *ReportLoader) LoadFromFile(filename string) (Report, error) {
file, err := os.Open(filename)
if err != nil {
return Report{}, err
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return Report{}, err
}
bs := make([]byte, stat.Size())
_, err = file.Read(bs)
if err != nil {
return Report{}, err
}
fileContent := string(bs)
lines := strings.Split(fileContent, "\n")
if len(lines) != 2 {
return Report{}, fmt.Errorf("invalid format")
}
title := lines[0]
content := lines[1]
return NewReport(title, content), nil
}
LoadFromFile 메서드는 이제 새로운 ReportLoader 구조체의 메서드로써 보고서 파일을 불러와 새로운 Report 인스턴스를 생성하여 반환하도록 수정하였습니다.
이렇게 Report 구조체의 책임을 다른 함수 또는 구조체로 분리하여 각각의 객체가 하나의 책임만을 지게 함으로써 앞서 단일 책임 원칙을 따르지 않는 코드에서 발생했던 기존의 보고서를 덮어쓰는 오류는 발생하지 않는 것을 확인할 수 있습니다.
package main
import (
"fmt"
"solid/srp"
)
func main() {
title := "초전도치의 비밀"
content := "초전도치는 비밀이다."
report1 := srp.NewReport(title, content)
saver := srp.ReportSaver{}
err := saver.SaveToFile(report1, "report.txt")
if err != nil {
panic(err)
}
loader := srp.ReportLoader{}
_, err = loader.LoadFromFile("report1.txt")
if err != nil {
panic(err)
}
fmt.Println(report1.Title == title)
fmt.Println(report1.Content == content)
}
$ go run main.go
true
true
🎯 단일 책임 원칙의 이점 정리
- 코드의 이해와 유지보수 용이성: 객체가 단일한 책임을 가짐으로써 코드의 작동 방식을 쉽게 파악할 수 있습니다. 이는 소프트웨어 유지보수를 더 쉽게 만듭니다.
- 유연성과 재사용성: 단일한 책임을 가지는 객체는 독립적으로 변경이 가능하므로 변경과 확장이 더 쉬워집니다. 또한 비슷한 기능이 필요한 다른 영역에서 코드를 재사용하기 용이합니다.
- 디버깅 및 테스트 용이성: 특정 기능을 담당하므로 버그를 추적하고 수정하기 용이합니다. 또한 단위 테스트를 수행하기 더 쉬워집니다.
🙏 마치며
아직 다른 원칙들은 살펴보지 않았으나, 단일 책임 원칙을 준수하는 것만으로도 자동으로 많은 문제들을 해결할 수 있다고 합니다. 그만큼 가장 기본적이고 중요한 원칙이니 코드를 작성할 때 의식적으로 단일 책임 원칙을 상기시키는 연습이 필요할 것 같습니다.
📖 참고자료
https://www.udemy.com/course/design-patterns-go/
글에서 수정이 필요한 부분이나 설명이 부족한 부분이 있다면 댓글로 남겨주세요!
'Go > 디자인 패턴' 카테고리의 다른 글
[Go] SOLID in Go - 리스코프 치환 원칙 (0) | 2023.10.13 |
---|---|
[Go] SOLID in Go - 개방 폐쇄 원칙 (0) | 2023.10.12 |
[Go] SOLID in Go - 패키지 (1) | 2023.10.10 |
[Go] SOLID in Go - 컴포지션 (1) | 2023.10.09 |
[Go] SOLID in Go - 인터페이스 (1) | 2023.10.04 |