클린코드 책과 제로베이스 [개발자와 함께 읽는 클린코드] 강좌를 기반으로 작성하였습니다.
이미지 출처 - https://medium.com/backticks-tildes/the-s-o-l-i-d-principles-in-pictures-b34ce2f1e898
Chapter3. 함수
어떤 프로그램이든 가장 기본적인 단위는 함수이다. 이 장은 함수를 잘 만드는 법에 대해 알려주고 있다.
01. SOLID 법칙
우선 책에 내용을 정리하기 전 SOLID 법칙에 대해 간단히 정리하고자 한다. SOLID 원칙은 객체지향 설계의 5가지 원칙이라고 하며, 책에서도 해당 방식을 언급하면서 설명하므로 알아줄 필요가 있다.
- SRP 단일 책임 원칙
- OCP 개방-폐쇄 원칙
- LSP 리스코프 치환 원칙
- ISP 인터페이스 분리 원칙
- DIP 의존성 역전 원칙
SRP(단일 책임 원칙)
한 클래스는 하나의 책임만 가져야 한다.
- 클래스는 하나의 기능만 가지며, 어떤 변화에 의해 클래스를 변경하는 이유는 오직 하나뿐이어야 한다.
- SRP 책임이 분명해지기 때문에, 변경에 의한 연쇄작용에서 자유로워질 수 있다.
- 가독성 향상과 유지보수에 용이해진다.
그림으로 설명하면 로봇이 하나의 클래스라고하면 로봇하나의 요리하기, 그리기, 운전하기 등을 한 곳에 다양한 역할을 선언하면 그 클래스의 해석하기도 어렵고 정체성도 알 기 어렵다. 그렇기 때문에 오른쪽 그림처럼 한 가지 책임(역할)만 가질 수 있게 클래스를 설계하여야 한다.
예시 코드 (Bad Code)
public class Report {
private String title;
private String content;
public Report(String title, String content) {
this.title = title;
this.content = content;
}
public void generateReport() {
// 보고서 생성 로직
}
public void saveToFile(String filePath) {
// 파일에 보고서 저장 로직
}
public void sendEmail(String recipient) {
// 이메일 전송 로직
}
}
이 클래스는 Report 라는 보고서를 생성하고, 파일에 저장하며, 이메일로 전송하는 책임을 가지고 있다. 단일 책임 원칙을 위배하고 있다.
예시 코드(Good Case)
public class Report {
private String title;
private String content;
public Report(String title, String content) {
this.title = title;
this.content = content;
}
public void generateReport() {
// 보고서 생성 로직
}
}
public class ReportSaver {
public static void saveToFile(Report report, String filePath) {
// 파일에 보고서 저장 로직
}
}
public class EmailSender {
public static void sendEmail(Report report, String recipient) {
// 이메일 전송 로직
}
이제 Report 클래스는 보고서 생성에만 집중하고, 파일 저장과 이메일 전송은 각각 ReportSaver와 EmailSender 클래스로 분리되었다. 이렇게 하면 각 클래스가 하나의 책임만을 가지므로 코드의 유지보수가 쉬워지고, 변경 사항이 발생할 때 해당 영역만 수정하면 된다.
OCP(개방-폐쇄 원칙)
소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
- 변경에 의한 비용한 기능한 줄이고, 확장을 위한 비용은 가능한 극대화 해야 한다.
- 요구사항의 변경이나 추가사항이 일어나더라도, 기존 구성요소에는 수정이 일어나지 않고, 기존 구성 요소를 쉽게 확장해서 재사용한다.
- 객체지향의 추상화와 다형성을 활용한다.
위 그림을 보면 클래스의 기능을 확장했을 때 기존의 기능을 수행못하는 문제가 발생된다. 클래스가 기능적으로 확장하더라도 기존 기능 또는 기존 선언 방식의 변화는 최소화하는 것이 개방폐쇄원칙이다.
예시코드(Bad Case)
public class Rectangle {
public double width;
public double height;
}
public class AreaCalculator {
public double calculateArea(Rectangle rectangle) {
return rectangle.width * rectangle.height;
}
}
이 코드에서 AreaCalculator 클래스는 사각형의 넓이를 계산하는 역할을 한다. 그러나 만약에 새로운 도형(예: 원)의 넓이를 계산하려면 AreaCalculator 클래스를 수정해야 한다. (추가 기능에 따른 기존 기능을 수정해야 되는 문제 발생)
예시코드(Good Case)
interface Shape {
double calculateArea();
}
public class Rectangle implements Shape {
private double width;
private double height;
// constructor, getters, and setters
@Override
public double calculateArea() {
return width * height;
}
}
public class Circle implements Shape {
private double radius;
// constructor, getters, and setters
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
public class AreaCalculator {
public double calculateArea(Shape shape) {
return shape.calculateArea();
}
}
개방폐쇄 원칙을 따르도록 리팩토링한 예제이다. 계산메서드를 Shape라는 인터페이스로 선언함으로써 모형이 추가됨에 따라 AreaCalculator를 수정할 필요 없이 인터페이스의 구현체를 만들어서 사용하면 된다. 이렇게 하면 기존 코드를 변경하지 않고도 새로운 기능을 추가할 수 있다.
LSP(리스코프 치환 원칙)
서브 타입은 언제나 기반 타입으로 교체할 수 있어야 한다.
- 서브 타입은 기반 타입이 약속한 규약(접근제한자, 예외 포함)을 지켜야 한다.
- 클래스 상속, 인터페이스 상속을 이용해 확장성을 획득한다.
- 다형성과 확장성을 극대화하기 위해 인터페이스를 사용하는 것이 더 좋다. (권장)
- 합성(composition)을 이용할 수 있다.
쉽게 이야기하면 자식 클래스는 부모 클래스로 형변환 될 수 있어야하며 부모 클래스의 기능을 사용할 수 있어야 한다.
ISP(인터페이스 분리 원칙)
자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다.
- 가능한 최소한의 인터페이스만 구현해야 한다.
- 만약 어떤 클래스를 이용하는 클라이언트가 여러 개고, 이들이 클래스의 특정 부분만 이용한다면, 여러 인터페이스로 분류하여 클라이언트가 필요한 기능 전달한다.
- SRP가 클래스의 단일 책임이라면, ISP는 인터페이스의 단일 책임이다.
정리하자면 클래스가 유효하지 않은 작업을 수행하는 경우 낭비 또는 버그를 양산할 수 있다. 그러므로 클래스는 해당 역할에 필요한 작업만 작성하며, 그 외 작업을 제거하거나 다른 클래스로 이동하여야 한다.
public interface Worker
{
void Work();
void Eat();
}
여기서 Worker 인터페이스의 기능은 work() 메서드이다. eat()은 필요가 없다. 그러므로 ISP를 위반한 것이 된다. Eater라는 인터페이스를 구성해 분리하는 것이 좋다.
DIP(의존성 역전 원칙)
상위모델은 하위모델에 의존하면 안된다.안 된다. 둘 다 추상화에 의존해야 한다. 추상화는 세부 사항에 의존해서는 안된다. 세부 사항은 추상화에 따라 달라진다.
- 하위 모델의 변경이 상위 모델의 변경을 요구하는 위계관계를 끊는다.
- 실제 사용관계는 그대로이지만, 추상화를 매개로 메시지를 주고받으면서 관계를 느슨하게 만든다.
코드예시(Bad Case)
class LightBulb {
public void turnOn() {
// 전구를 켜는 로직
}
public void turnOff() {
// 전구를 끄는 로직
}
}
class Switch {
private LightBulb bulb;
public Switch() {
this.bulb = new LightBulb();
}
public void operate() {
// 스위치 동작 시 전구를 키거나 끄는 로직
if (/* some condition */) {
bulb.turnOn();
} else {
bulb.turnOff();
}
}
}
해당 코드는 전구의 불을 끄는 기능을 수행하고 있다. 만약 전구가 아닌 다른 디바이스의 전원을 온오프 한다면 새로운 클래스를 만들어야 하고 그것도 직접 의존하여 추가해줘야 한다.
그러므로 DIP를 위반 한다고 볼 수 있다.
예제코드(Good Case)
interface Switchable {
void turnOn();
void turnOff();
}
class LightBulb implements Switchable {
@Override
public void turnOn() {
// 전구를 켜는 로직
}
@Override
public void turnOff() {
// 전구를 끄는 로직
}
}
class Switch {
private Switchable device;
public Switch(Switchable device) {
this.device = device;
}
public void operate() {
// 스위치 동작 시 전구를 키거나 끄는 로직
if (/* some condition */) {
device.turnOn();
} else {
device.turnOff();
}
}
}
중간에 인터페이스를 구현함으로써 객체의 직접 의존하지 않게 변경되었다. 이러한 관계가 고수준 모듈(Switch)과 저수준 모듈(LightBulb) 간의 의존성이 역전되었다고 표현한다.
SOLID 원칙에 대해 알아봤다. 이제 본격적으로 클린코드 내용을 정리해 보자.
2. 간결하게 함수 작성하기
함수는 만드는 규칙은 무조건 작게 만드는 것이다. 큰 함수를 쪼개서 적절한 이름을 붙여주면 코드를 이해하기 훨씬 쉬워진다.
위 코드는 클린 코드 예제 코드이다. 위 코드를 읽고 해석하기는 매우 어렵다. 일단 함수가 길고 여러 기능이 섞여있기 때문이다. (추상화 레벨로 각각이다.)
긴 함수를 작게 쪼갠것이다. 함수 내 추상화 수준을 동일하게 맞춤으로써 훨씬 읽기 수월해졌다. 테스트페이지면 Setup과 TearDown을 수행하고 아니면 그냥 페이지를 출력한다.라고 쉽게 읽을 수 있다.
2-1 블록과 들여쓰기
- if 문 / else 문 / while 문에 들어가는 블록은 한 줄이어야 한다.
- 블록 안에 호출하는 함수를 이름을 적절히 명명하여, 코드를 이해하기 쉽게 한다.
- 중첩구조가 생길 만큼 함수가 커지면 안 된다. 그리므로 들여 쓰기 수준은 1단이나 2단을 넘어서면 안 된다.
2-2 추상화 레벨 맞추기
추상화 레벨이란 해당 코드를 읽으면서 파악할 수 있는 정보의 수준을 의미한다. 즉, 해당 코드로 정보를 알 수 있으면 추상화 더 낮아진다고 할 수 있다.
그렇기 때문에 한 함수에 여러 추상화 수준을 섞으면 읽는 사람이 높은 추상화 수준인지 낮은 추상화 수준인지 구분하기 어렵다.
3. 한 가지만 하기(SRP), 변경에 닫게 만들기(OCP)
해당 코드는 직원 유형에 따른 다른 값을 계산하여 반환해 주는 함수이다.
위 함수는 여러 문제를 발생시킨다.
- 직원에 유형이 증가하면 코드가 더 길어진다.
- type에 따른 함수를 호출하기 때문에 한 가지 작업만 수행하지 않는다. (SRP 위반)
- 위 종류와 같은 코드가 무한정 존재하게 되며 새 직원 유형이 발생되면 그 코드들을 다 수정해줘야 한다. (OCP 위반)
위 코드를 개선해보자.
계산과 타입관리 클래스를 분리하여 관리한다. 타입유형에 대한 인터페이스를 구현함으로써 타입에 대한 처리는 최대한 Factory에서만 진행하도록 한다. (저자는 switch 코드에 대한 처리는 위 같은 다형적 객체를 생성하는 코드에 대해서만 허용한다고 한다.)
Employee가 필요한 기능 다 추상화 처리하여 직원 별 기능에 대응할 수 있게 하였다.
4. 함수 인수
인수 갯수는 0~2개가 적당하다. 인수 항이 늘어날수록 함수를 이해하기 어렵게 만들기 때문이다.
// 객체를 인자로 넘기기
Circle makeCircle(double x, double y, double radius);
Circle makeCircle(Point center, double radius);
// 가변인자 넘기기 -> 특별한 경우가 아니면 잘 활용되지는 않는다.
String.format(String format, Object... args);
5. 부수 효과(Side Effect) 없는 함수
값을 변환하는 함수에서 외부 상태까지 변경하는 경우, 부수효과를 발생시킬 수 있다.
해당 메서드의 이름만 봤을때는 이름과 패스워드 인자를 받으면 해당 정보가 맞는지 확인만 해주는 메서드로 인지된다.
하지만 해당 함수를 보면 세션을 초기화해 주는 로직이 들어가 있다. 이러면 함수명칭만 보고 로직을 사용한다면 세션이 초기화될 수 있는 부수효과를 일으키게 된다.
이런 경우 함수 분리하여 나타내는 것이 가장 이상적인 방법이다.
정리를 마치며
책에서는 내가 정리한 내용보다 더 많은 내용을 담고 있다. 위 내용은 프로젝트 함수 리팩토링을 하면서 필요한 내용들을 정리한 것이다.
좋은 함수 작성을 처음 해보면 시작하기도 쉽지 않다. 책에서 추천해 준 방식처럼 일단 러프하게 작성한 다음 테스트 코드를 작성하고 후에 리팩토링을 하나하나 위 기준에 맞춰 수정한다. 를 많이 연습해 봐야 될 것 같다.
'Language > TDD, 클린코드, 리펙토링' 카테고리의 다른 글
[클린코드] 4장. 주석 (0) | 2023.12.14 |
---|---|
[클린코드] 1장 깨끗한 코드, 2장 의미있는 이름을 읽고 정리한 내용 (0) | 2023.12.07 |
[TDD] getter 대신 메시지를 던져라 (0) | 2023.09.03 |
[TDD] 테스트가 힘든 코드를 테스트 가능한 구조로 변경하기 (0) | 2023.08.29 |