개발을 하다 보면 이런 말을 종종 듣을 수 있습니다.
"유지보수가 어렵다", "코드가 너무 얽혀 있다", "기능 하나 수정했는데 다른 데서 에러가 난다"는 문제를 자주 마주하게 됩니다.
이럴 때 등장하는 것이 바로 SOLID 원칙입니다.
SOLID는 객체지향 설계의 5가지 원칙을 뜻하며, 코드를 유지보수하기 쉽고 확장 가능하며 유연하게 만드는 데 도움을 줍니다.
SOLID는 5가지 원칙의 앞 글자를 딴 것입니다.
S : 단일 책임 원칙 (Single Responsibility Principle)
O : 개방/폐쇄 원칙 (Open/Closed Principle)
L : 리스코프 치환 원칙 (Liskov Substitution Principle)
I : 인터페이스 분리 원칙 (Interface Segregation Principle)
D : 의존 역전 원칙 (Dependency Inversion Principle)
"한 클래스는 하나의 책임만 가져야 한다."
@Service
public class UserService {
private static final Logger logger = LoggerFactory.getLogger(UserService.class);
public static UserService createUserService() {
logger.info("UserService 인스턴스 생성됨");
return new UserService();
}
public void registerUser(String username) {
logger.info("유저 등록 시도: {}", username);
// 사용자 저장 로직 (예: DB 저장)
System.out.println(username + " 등록 완료");
}
public void sendWelcomeEmail(String username) {
logger.info("환영 이메일 전송: {}", username);
// 이메일 전송 로직
System.out.println("환영 이메일 전송 완료: " + username);
}
}
UserService가 너무 많은 책임을 가지고 있습니다
// 도메인 객체
class User {
private String username;
public User(String username) {
this.username = username;
}
public String getUsername() {
return username;
}
}
// 이메일 전송 책임
@Service
class EmailService {
private final Logger logger = LoggerFactory.getLogger(EmailService.class);
public void sendWelcomeEmail(User user) {
logger.info("환영 이메일 전송: {}", user.getUsername());
// 실제 이메일 전송 코드
System.out.println("환영 이메일 전송 완료: " + user.getUsername());
}
}
// 유저 등록 로직
@Service
class UserService {
private final EmailService emailService;
private final Logger logger = LoggerFactory.getLogger(UserService.class);
public UserService(EmailService emailService) {
this.emailService = emailService;
}
public void registerUser(User user) {
logger.info("유저 등록 시도: {}", user.getUsername());
// DB 저장 로직
System.out.println(user.getUsername() + " 등록 완료");
emailService.sendWelcomeEmail(user);
}
}
// 팩토리 메서드 책임 분리
@Component
class UserServiceFactory {
private final EmailService emailService;
public UserServiceFactory(EmailService emailService) {
this.emailService = emailService;
}
public UserService create() {
Logger logger = LoggerFactory.getLogger(UserServiceFactory.class);
logger.info("UserService 인스턴스 생성됨");
return new UserService(emailService);
}
}
User: 도메인 모델
EmailService: 알림 전송 책임
UserService: 유저 등록 비즈니스 로직
UserServiceFactory: 팩토리 역할만 담당
Logger는 각 클래스 안에서 자기 책임 범위 내에서만 사용
“소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.”
@Service
public class PaymentService {
public void pay(String paymentType, int amount) {
if (paymentType.equals("card")) {
System.out.println("카드로 " + amount + "원 결제합니다.");
} else if (paymentType.equals("kakao")) {
System.out.println("카카오페이로 " + amount + "원 결제합니다.");
} else if (paymentType.equals("naver")) {
System.out.println("네이버페이로 " + amount + "원 결제합니다.");
} else {
System.out.println("지원하지 않는 결제 수단입니다.");
}
}
}
// 결제 전략 인터페이스
public interface PaymentStrategy {
boolean supports(String paymentType); // 어떤 타입인지 판별
void pay(int amount); // 결제 실행
}
// 카드 결제 구현체
public class CardPayment implements PaymentStrategy {
@Override
public boolean supports(String paymentType) {
return paymentType.equals("card");
}
@Override
public void pay(int amount) {
System.out.println("💳 카드로 " + amount + "원 결제합니다.");
}
}
// 카카오페이 결제 구현체
public class KakaoPayment implements PaymentStrategy {
@Override
public boolean supports(String paymentType) {
return paymentType.equals("kakao");
}
@Override
public void pay(int amount) {
System.out.println("📱 카카오페이로 " + amount + "원 결제합니다.");
}
}
// 결제 서비스
public class PaymentService {
private final Map<String, PaymentStrategy> strategyMap;
public PaymentService(List<PaymentStrategy> strategies) {
this.strategyMap = new HashMap<>();
for (PaymentStrategy strategy : strategies) {
strategyMap.put(strategy.getType(), strategy);
}
}
public void pay(String paymentType, int amount) {
PaymentStrategy strategy = strategyMap.get(paymentType);
if (strategy != null) {
strategy.pay(amount);
} else {
System.out.println("🚫 지원하지 않는 결제 수단입니다: " + paymentType);
}
}
}
“프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.”
// 부모 클래스: 직사각형
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int getArea() {
return width * height;
}
}
// 자식 클래스: 정사각형
public class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width; // 정사각형은 너비와 높이가 항상 같아야 하니까
}
@Override
public void setHeight(int height) {
this.width = height; // 정사각형은 높이도 같게 맞춤
this.height = height;
}
}
public interface Shape {
int getArea();
}
// 직사각형
public class Rectangle implements Shape {
private int width;
private int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
@Override
public int getArea() {
return width * height;
}
}
// 정사각형
public class Square implements Shape {
private int side;
public Square(int side) {
this.side = side;
}
@Override
public int getArea() {
return side * side;
}
}
Rectangle과 Square의 상속관계에서 Shape과 Rectangle, Square과 Shape의 구현체로 변경해 서로의 특징을 보장할 수 있습니다.
“특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.”
public interface Machine {
void print(Document doc);
void scan(Document doc);
void fax(Document doc);
}
// 단순 프린터
public class SimplePrinter implements Machine {
public void print(Document doc) {
System.out.println("프린트 완료");
}
public void scan(Document doc) {
throw new UnsupportedOperationException("스캔 기능 없음");
}
public void fax(Document doc) {
throw new UnsupportedOperationException("팩스 기능 없음");
}
}
SimplePrinter는 프린트 기능만 필요한데도 스캔, 팩스 메서드를 구현해야 합니다 (불필요한 의존성 발생)
변경이 있을 경우, 쓰지 않는 메서드 때문에 부작용이 생길 수도 있습니다
public interface Printer {
void print(Document doc);
}
public interface Scanner {
void scan(Document doc);
}
public interface Fax {
void fax(Document doc);
}
public class SimplePrinter implements Printer {
public void print(Document doc) {
System.out.println("프린트 완료");
}
}
public class MultiFunctionPrinter implements Printer, Scanner, Fax {
public void print(Document doc) { /* 구현 */ }
public void scan(Document doc) { /* 구현 */ }
public void fax(Document doc) { /* 구현 */ }
}
불필요한 메서드 강제 구현을 피하기 위해 인터페이스를 기능별로 분리
프로그래머는 “추상화에 의존해야지, 구체화에 의존하면 안된다.”
public class Kid {
private Robot toy;
public Kid(Robot toy) {
this.toy = toy;
}
public void play() {
toy.activate();
}
}
public class Robot {
public void activate() {
System.out.println("🤖 로봇이 움직입니다!");
}
}
Kid가 Lego를 장난감으로 삼는다면 Kid코드를 직접 고쳐야하는 문제가 발생합니다. 구체화에 의존하고 있음 → DIP 위반
// 추상화
public interface Toy {
void play();
}
// 구체화 1
public class Robot implements Toy {
public void play() {
System.out.println("🤖 로봇이 움직입니다!");
}
}
// 구체화 2
public class Lego implements Toy {
public void play() {
System.out.println("🧱 레고로 탑을 쌓습니다!");
}
}
// 고수준 모듈
public class Kid {
private Toy toy;
public Kid(Toy toy) {
this.toy = toy;
}
public void play() {
toy.play();
}
}
Kid는 Toy라는 추상화에만 의존하므로, 어떤 장난감을 쓰든 Kid 코드를 수정할 필요 없게 됩니다
즉, 새로운 장난감이 생겨도 쉽게 확장 가능하게 되었습니다
1.유지보수가 쉬워집니다
하나 고치면 연쇄적으로 에러가 나는 현상 줄어듭니다.
2.새로운 기능 추가가 편해집니다
기존 코드 건드릴 필요 없이 확장할 수 있습니다.
3.협업이 쉬워집니다
각 클래스/모듈의 역할이 분명해서 다른 사람이 코드를 봐도 이해하기 쉬워집니다.
4.테스트 코드 작성이 쉬워집니다
의존성 분리 덕분에 단위 테스트나 Mocking이 쉬워집니다.
SOLID 원칙은 무조건 지켜야 하는 법칙은 아니지만, 객체지향 설계를 더 튼튼하고 유연하게 만들 수 있는 가이드라인입니다.
처음엔 어렵게 느껴질 수도 있지만, 점점 프로젝트를 진행하면서 "아, 그래서 이게 필요했구나!" 하고 체감하게 됩니다.