클린 코드는...
⇒ 개발자의 논리적인 사고 흐름이 반영된 좋은 코드는 협업을 원활하게 하고 시간이 지나도 견고하게 유지되며 빠른 의사소통으로 팀 전체의 생산성을 높인다
클린코드 팁 : 모듈화는 기본, 코드는 한 번 작성하고 여러 번 본다
⇒ 처음부터 잘 쓰자
⬌ 코드 스멜
잠재적인 문제를 나타내는 징후
경직되고(Rigid), 깨지기 쉽고(Fragile), 재사용하기 어려움(Immobile)
클린 코드를 만들어가는 핵심적인 활동이자 코드 구조를 건강하게 개선하는 활동
소프트웨어의 겉보기 동작은 그대로 유지한 채, 내부 구조를 변경하여 이해하고 수정하기 쉽게 만드는 과정 (버그를 잡거나 새로운 기능을 추가하는 것 X)
SOLID 원칙으로 설계의 뼈대와 방향을 잡고 → 디자인 패턴으로 문제를 풀며 → 리팩토링으로 다듬어 품질을 높이고 → 결국 클린코드로 완성해간다
클린코드는 결과물이자 개발자가 추구해야 할 목표이며 방향!


| S | O | L | I | D |
|---|---|---|---|---|
| Single Responsibility | Open /Closed | Liskov Substitution | Interface Segregation | Dependency Inversion |
| 단일 책임 원칙 | 개방-폐쇄 원칙 | 리스코프 치환 원칙 | 인터페이스 분리 원칙 | 의존성 역전 원칙 |
SOLID는 변화에 강하고 재사용에 유리한 클래스 구조를 만드는 원칙이다
소프트웨어는 끊임없이 변화하고 성장하는데, 좋은 설계를 통해 미래의 변경에 유연하게 대처할 수 있게 해야 한다
SOLID를 적용하면 유지보수하기도 쉽고, 확장도 가능하고, 재사용성을 높이며, 테스트하기에도 편하다
SOLID는 서로 개념적으로 연관되어 있으며, 모든 원칙을 적용할 필요는 없다
결합도 : 모듈과 모듈 간의 의존 정도
결합도가 낮으면 → 클래스 간의 관계가 느슨해서 자유로운 교체 가능
응집도 : 한 모듈 내 구성 요소의 연관 정도
응집도가 높으면 → 클래스 내부가 하나의 목적에 집중
기존 코드를 어떻게 개선해야 할까에 대한 방향을 제시하기 때문에, SOLID 원칙을 따르면 코드의 변경 지점이 명확해지고 의존성이 낮아져 부작용 없이 안전하게 코드를 개선할 수 있다
처음부터 유지보수와 확장이 용이한 구조를 만들고, 리팩토링 시 얽혀있던 의존성을 끊고 코드의 구조를 개선하여 미래에 발생할 여러 문제점들을 예방하고 안전하게 해결할 수 있다
리팩토링뿐만 아니라 스프링 프레임워크의 핵심 철학이다
스프링의 핵심인 제어의 역전(IoC)와 의존성 주입(DI)은 의존성 역전 원칙(DIP)과 개방-폐쇠 원칙(OCP)을 가장 잘 구현한 대표적인 예이다
또한, MSA(마이크로서비스아키텍처)와 같이 변화에 유연하게 대응해야 하는 현대적인 아키텍처를 설계할 때 기본적으로 각 서비스의 역할을 명확히 나누고(SRP, ISP) t서비스 간의 결합도를 낮추는(DIP) 원칙이 활용된다
클래스는 단 하나의 기능(책임)에 집중하도록 분리한다 ⇒ 응집도 ↑
여러 책임을 가지면 한 책의 변경이 다른 책임에 영향을 주기 때문
코드를 이해하기 쉽게 만들고 코드를 변경했을 때의 영향이 명확해진다 ⇒ 재사용성 ↑
//before
class ReportService {
public void generateReport() { /* 보고서 생성 */ }
public void sendemail() { /* 이메일 전송 */ }
}
→ ReportService가 보고서 생성과 이메일 전송을 모두 처리함
//after
class ReportGenerator {
public void generateReport() { /* 보고서 생성 */ }
}
class EmailSender {
public void sendemail() { /* 이메일 전송 */ }
}
→ 보고서 생성과 이메일 전송 기능이 분리됨, ReportGenerator 클래스는 보고서 생성이라는 기능만, EmailSender 클래스는 이메일 전송이라는 기능만 수행하고 있음
확장에는 열려있고, 수정에는 닫혀 있어야 한다
(수정은 기존 코드 뜯어 고치는 것, 확장은 기존 코드에 새 기능을 연결시키는 것)
기존 코드를 변경하지 않고도 새로운 기능을 추가할 수 있어야 함 ⇒ 유연성 ↑
추상화(인터페이스, 추상 클래스)에 의존하고, 구체적인 구현 클래스에 의존하지 않는다
//before
public class PaymentProcessor {
public void process(String paymentType) {
if ("creditCard".equals(paymentType)) {
// 신용카드 결제 로직
} else if ("kakaoPay".equals(paymentType)) {
// 카카오페이 결제 로직
}
// 새로운 결제 수단(ex: NaverPay)이 추가될 때마다 이 부분을 수정해야 함!
}
}
→ 새로운 결제 수단 추가 시 기존 코드를 수정해야 함
//after
// 결제 수단 인터페이스
public interface PaymentMethod {
void pay();
}
// 신용카드 결제
public class CreditCard implements PaymentMethod {
@Override
public void pay() { /* 신용카드 결제 로직 */ }
}
// 카카오페이 결제
public class KakaoPay implements PaymentMethod {
@Override
public void pay() { /* 카카오페이 결제 로직 */ }
}
// 결제 처리기
public class PaymentProcessor {
public void process(PaymentMethod paymentMethod) {
paymentMethod.pay(); // 어떤 결제 수단이 와도 코드는 동일
}
}
→ 새로운 결제수단 추가 시 기존 코드를 수정하지 않고 새로운 클래스를 추가하고 인터페이스를 구현하여 확장 가능
하위 타입은 언제나 상위 타입으로 대체될 수 있어야 한다
자식 클래스는 부모 클래스가 사용되는 곳에 문제없이 들어갈 수 있어야 한다
⇒ 자식 클래스는 부모 클래스의 행동 규약을 위반해서는 안 됨
⇒ 상속은 'IS-A' 관계를 명확히 따를 때 사용
//before
class Bird {
public void fly() { /* 날기 */ }
}
class Ostrich extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("타조는 못 날아요");
}
}
→ Bird 클래스를 상속한 Ostrich 클래스도 fly()를 오버라이드해야 하므로 문제(예외)가 발생함
//after
interface Bird {
void move();
}
class FlyingBird implements Bird {
public void move() { System.out.println("날아요"); }
}
class WalkingBird implements Bird {
public void move() { System.out.println("걸어요"); }
}
→ Bird 인터페이스를 만들어 FlyingBird, WalkingBird로 분리함 // 이게 교수님이 이전에 말씀하셨던 상속 회피와 관련되는 내용인 것 같다
클라이언트는 자신이 사용하지 않는 메소드에 의존해서는 안된다
하나의 거대한 인터페이스보다, 여러 개의 구체적인 인터페이스가 낫다 ⇒ 결합도 ↓
클래스가 불필요한 메소드를 구현하지 않도록 분리한다 ⇒ SRP와 관련됨
인터페이스 변경 시에 영향 받는 클래스를 최소화한다
//before
interface Worker {
void work();
void eat();
}
class Robot implements Worker {
public void work() { /* 작업 */ }
public void eat() { throw new UnsupportedOperationException(); }
}
→ Worker 인터페이스에 work(), eat()이 있기 때문에 Robot이 불필요한 eat()까지 구현해야 함
//after
interface Workable {
void work();
}
interface Eatable {
void eat();
}
class Robot implements Workable {
public void work() { /* 작업 */ }
}
→ 기능을 기준으로 Workable, Eatable 인터페이스로 분리하여 Robot이 필요한 기능만 구현할 수 있도록 함
상위 모듈은 하위 모듈에 의존해서는 안 되고, 둘 다 추상화에 의존해야 한다
의존 관계를 맺으려면 변화 빈도가 높은 구체적인 구현 클래스(하위 모듈)에 직접 의존하는 것이 아니라, 변화 빈도가 낮은 인터페이스나 추상 클래스(추상화)에 의존하라는 의미
의존성의 방향을 역전시켜 유연한 관계를 만든다 ⇒ OCP를 가능하게 하는 핵심 원칙
//before
class Light {
public void turnOn() {
System.out.println("💡 불 켜짐");
}
public void turnOff() {
System.out.println("💡 불 꺼짐");
}
}
class SmartHomeSwitch {
private Light light;
public SmartHomeSwitch() {
this.light = new Light(); // 구체 클래스에 직접 의존
}
public void operate(String command) {
if (command.equals("on")) light.turnOn();
else light.turnOff();
}
}
→ SmartHomeSwitch Light라는 구현 클래스에 직접 의존하고 있어서 다른 장치는 제어할 수가 없음, 추가하려면 코드를 수정해야 함
//after
// 추상화
interface Device {
void turnOn();
void turnOff();
}
// 다양한 가전제품 구현
class Light implements Device {
public void turnOn() { System.out.println("💡 불 켜짐"); }
public void turnOff() { System.out.println("💡 불 꺼짐"); }
}
class AirConditioner implements Device {
public void turnOn() { System.out.println("❄️ 에어컨 ON"); }
public void turnOff() { System.out.println("❄️ 에어컨 OFF"); }
}
class CoffeeMachine implements Device {
public void turnOn() { System.out.println("☕ 커피 추출 시작"); }
public void turnOff() { System.out.println("☕ 커피 추출 종료"); }
}
// 스마트홈 스위치는 추상화에만 의존하여 여러개의 device와 연결
public class SmartHomeSwitch {
private final List<Device> devices;
public SmartHomeSwitch(List<Device> devices) {
this.devices = devices;
}
public void operateAll(String command) {
for (Device device : devices) {
if ("on".equals(command)) device.turnOn();
else device.turnOff();
}
}
public void operateByType(Class<? extends Device> type, String command) {
for (Device device : devices) {
if (type.isInstance(device)) {
if ("on".equals(command)) device.turnOn();
else device.turnOff();
}
}
}
}
→ Device라는 인터페이스를 도입하여 사용함으로써 SmartHomeSwitch는 추상화에만 의존하여 여러 개의 장치와 연결되어 있음, 어떤 장치든 Device만 구현하면 즉시 연결할 수 있어 확장성이 높음
제어의 역전(IoC, Inverison of Control)
전통적인 방식에서는 개발자가 작성한 코드가 객체를 생성하고 의존성을 연결하는 등 모든 제어의 흐름을 직접 관리함
IoC는 이 제어의 흐름을 역전시키는 것으로, 객체의 생성부터 생명주기 관리까지 모든 것을 개발자가 아닌 프레임워크에 위임함
개발자는 어떤 부품이 필요한지만 알려주고, 조립은 프레임워크라는 전문가가 알아서 해주는 원리!
의존성 주입(DI, Dependency Injection)
DI는 IoC를 구현하는 대표적인 기술
클래스 내부에서 new를 사용해 의존 객체를 직접 생성하는 것이 아니라, 외부(프레임워크)에서 의존 객체를 전달(주입)받는 방식
final 키워드를 사용할 수 있어 의존성이 런타임에 변경되는 것 방지 Setter 주입 : 의존성이 필수가 아닌 선택사항일 때 유용, Setter가 호출되기 전까지 의존성은 null이번 수업이 정말 자바에 대한 새로운 시야를 열어준 느낌이다.
교수님께서 전에 수업 중 코드 설명하시면서 복잡하고 있어보이는 게 좋은 게 아니라
간단하고 확장하기 편한 게 좋은 코드라고 말씀하셨던 적이 여러 번 있었다.
그때마다 사실은 뭔가 어렴풋이...? 느낌적으로만,.. 받아들였던 것 같은데,
이런 SOLID 원칙에 의한 것이었음을 알게 되어서 비로소 이해가 되는 기분이다.
그동안 생각해왔던 무조건 짧고 간결한 코드!가 아니라 코드는 길어보일지라도
실제 기능에 따라 쪼개고 더 이해하기 쉽게 만드는 게 훨씬 중요하다는 걸...
어찌 보면 너무나 당연한 걸 이제야 제대로 실감해버렸다.
아직도 코드를 쓰는 것은 너무 어렵게 느껴지지만,
(일단 돌아가는 것에 급급하고... 늘고 있는지도 모르겠지만 ㅠㅠ)
내가 노력하고 연습해야 하는 방향성에 대해서 알게 되어서 정말 유익했다고 생각한다.