오늘

안녕하세요, 아기사자 여러분! 14기 백엔드 운영진 우태호입니다. 🦁
이제 막 코딩의 재미를 알아가는 여러분에게, 조금 무거울 수 있지만 여러분들의 기본기가 될 'SOLID 원칙' 에 대해 다뤄보고자 합니다.
'이거 백엔드 얘기 아니야?'라고 생각할 수 있지만, 사실 좋은 설계는 분야를 가리지 않아요. 잘 짜인 컴포넌트 하나가 열 기능 부럽지 않은 것처럼, 우리가 작성하는 코드에 질서를 부여하는 약속이거든요.
한 가지 더. 요즘처럼 AI가 코드를 뚝딱 만들어주는 '바이브 코딩' 이 대세인 지금일수록, SOLID 원칙의 가치는 오히려 더 올라갑니다. AI가 내뱉은 코드가 진짜 좋은 코드인지, 나중에 고생하지 않을 코드인지 판별하려면 결국 개발자 본인에게 탄탄한 설계 기준이 있어야 하니까요. SOLID를 이해하는 순간, 여러분은 프로젝트와 코드 리뷰에서 가장 강력한 무기를 쥐게 되는 셈입니다.
처음부터 완벽할 순 없겠지만, 이 글을 읽고 나면 여러분의 코드가 어제보다 조금 더 '단단'해질거라 확신합니다. 아기사자 여러분의 성장을 응원하며, 최대한 쉽게 풀어봤으니 가벼운 마음으로 따라와 보세요!

SOLID는 로버트 C. 마틴(Uncle Bob)이 정리한 객체지향 설계의 5가지 원칙을 앞 글자만 모은 약어예요. 2000년대 초에 등장했지만, 20년이 넘은 지금도 취업 면접 단골 질문으로 나오고 실무에서도 매일 체감되는 원칙들입니다. "면접용으로 외웠다가 실무에선 안 쓰더라"가 아니라, 제대로 이해하면 코드를 짤 때마다 자연스럽게 머릿속에서 체크하게 되는 수준이 됩니다.
S: Single Responsibility Principle (단일 책임 원칙)
O: Open/Closed Principle (개방-폐쇄 원칙)
L: Liskov Substitution Principle (리스코프 치환 원칙)
I: Interface Segregation Principle (인터페이스 분리 원칙)
D: Dependency Inversion Principle (의존성 역전 원칙)
이 다섯 가지 원칙을 이해하면 변경에 강하고, 읽기 쉽고, 테스트하기 좋은 코드를 만들 수 있어요. 이 글에서는 단순히 "이런 원칙이 있다"에서 끝내지 않고, 원칙끼리 충돌할 때는 어떻게 절충해야 하는지까지 실제 코드 예제와 함께 파고들어볼 거예요. 긴 이론 설명보다 코드로 직접 보여주는 게 훨씬 빠르니까, 바로 시작해 보겠습니다!
"하나의 클래스는 하나의 책임만 가져야 한다."
가장 직관적인 원칙이에요. 클래스 하나가 너무 많은 일을 하면, 어느 한 곳을 수정했을 때 다른 기능이 덩달아 망가질 위험이 생깁니다. 아래 코드를 볼까요?
백엔드에서 진행했던 세션이라 예제 코드가 Java 인 점 양해 부탁드려요ㅠㅠ
// ❌ UserManager가 너무 많은 일을 함
public class UserManager {
// 책임 1: 사용자 저장
public void saveUser(User user) {
// SQL 실행...
System.out.println("DB에 사용자 저장: " + user.getName());
}
// 책임 2: 이메일 발송
public void sendWelcomeEmail(User user) {
// 메일 전송...
System.out.println("환영 이메일 발송: " + user.getEmail());
}
// 책임 3: 로그 기록
public void logUserActivity(User user, String action) {
// 로그 포맷팅...
System.out.println("[LOG] " + user.getName() + " -> " + action);
}
}UserManager 라는 클래스 하나가 DB 저장, 메일 발송, 로그 기록을 모두 담당하고 있죠. DB 로직이 바뀌거나, 메일 로직이 바뀌거나 할 때마다 이 클래스를 손대야 합니다. 기능이 뒤섞여 있으니, 한 군데 고치다가 다른 기능이 엉망이 될 수 있어요.
SRP를 적용하면 이렇게 됩니다.
// ✅ 각 클래스는 딱 하나의 책임만
public class UserRepository {
public void save(User user) {
// SQL 실행...
System.out.println("DB에 사용자 저장: " + user.getName());
}
}
public class EmailService {
public void sendWelcomeEmail(User user) {
// 메일 전송...
System.out.println("환영 이메일 발송: " + user.getEmail());
}
}
public class Logger {
public void log(String message) {
// 로그 포맷팅...
System.out.println("[LOG] " + message);
}
}
// 조율자: 각 클래스를 가져다 씀
public class UserRegistrationService {
private UserRepository userRepository = new UserRepository();
private EmailService emailService = new EmailService();
private Logger logger = new Logger();
public void register(User user) {
userRepository.save(user);
emailService.sendWelcomeEmail(user);
logger.log(user.getName() + " 가입 완료");
}
}코드에 어떤 변화가 있었나요? 하나의 클래스에 뭉쳐 있던 각 기능들이 각자의 클래스로 분리되었죠! 이렇게 설계한다면 이제 DB 로직이 바뀌면 UserRepository만, 메일 로직이 바뀌면 EmailService만 수정하면 됩니다. 서로 영향을 주지 않으니 훨씬 안전하고, 나중에 코드를 다시 봤을 때 어디를 고쳐야 할지 바로 보이죠.
"확장엔 열려 있고, 수정엔 닫혀 있어야 한다."
처음엔 좀 아리송하게 들릴 수 있어요. 쉽게 말하면, 새 기능을 추가할 때 기존 코드를 건드리지 않아야 한다는 뜻입니다. 결제 기능으로 예를 들어볼게요.
// ❌ 새 결제수단 추가할 때마다 기존 코드를 수정해야 함
public class PaymentService {
public void pay(String type, int amount) {
if (type.equals("신용카드")) {
System.out.println("신용카드로 " + amount + "원 결제");
} else if (type.equals("KAKAO")) {
System.out.println("카카오페이로 " + amount + "원 결제");
} else if () {
// 네이버페이, 토스 추가? → 여기를 또 수정해야 함 ❌
}
}
}결제 수단이 하나 늘 때마다 if-else가 길어지고, 기존 코드를 계속 건드려야 합니다. 실수로 기존 결제 로직을 망가뜨리면? 상상만해도 아찔합니다. OCP를 적용해볼까요?
// ✅ 새 결제수단은 클래스만 추가하면 됨. 기존 코드 건드릴 필요 없음
public interface PaymentMethod {
void pay(int amount);
}
public class CardPayment implements PaymentMethod {
@Override
public void pay(int amount) {
System.out.println("신용카드로 " + amount + "원 결제");
}
}
public class KakaoPayment implements PaymentMethod {
@Override
public void pay(int amount) {
System.out.println("카카오페이로 " + amount + "원 결제");
}
}
// 나중에 네이버페이 추가? → 클래스 하나만 더 만들면 됨!
public class NaverPayment implements PaymentMethod {
@Override
public void pay(int amount) {
System.out.println("네이버페이로 " + amount + "원 결제");
}
}
public class PaymentService {
public void pay(PaymentMethod method, int amount) {
method.pay(amount); // 어떤 결제수단이 와도 동작
}
}이제 새 결제수단을 추가하고 싶으면 클래스 하나만 새로 만들면 됩니다. PaymentService나 기존 결제 클래스는 손댈 필요가 전혀 없어요. 기존 코드는 안전하고, 확장은 자유롭습니다. 확장엔 열려(Open) 있고, 수정엔 닫혀(Closed) 있다는 말이 이제 이해가 가시죠?
수정할 때도 마찬가지예요. 카카오페이 로직만 바꾸고 싶다면 KakaoPayment만 열면 됩니다. CardPayment도, PaymentService도 건드릴 필요가 없어요. 앞에서 배운 SRP(단일 책임 원칙) 덕분이에요. 각 결제 클래스가 "자기 결제 방식 하나"만 책임지고 있으니까, 수정할 때도 그 클래스 안에서만 일이 끝납니다. SRP가 잘 지켜진 구조일수록 OCP도 자연스럽게 따라오는 셈이죠.
"자식 클래스는 부모 클래스를 완전히 대체할 수 있어야 한다."
부모 클래스 자리에 자식 클래스를 넣어도 프로그램이 정상적으로 동작해야 한다는 원칙이에요. 유명한 펭귄 예제로 살펴보겠습니다.
// ❌ 부모를 자식으로 바꿨더니 프로그램이 망가짐
public class Bird {
public void fly() {
System.out.println("I believe I can fly!");
}
}
public class Penguin extends Bird {
@Override
public void fly() {
// 펭귄은 못 날아 → 예외를 던짐
throw new UnsupportedOperationException("Penguin Ggoggodak");
}
}
// 펭귄은 날 수 없으니까, 호출되면 에러를 던짐 ❌
public void makeBirdFly(Bird bird) {
bird.fly();
}팽귄이 하늘을 날 수 있었다면 지금만큼의 인기는 없었을 거에요.. Bird 자리에 Penguin을 넣었더니 프로그램이 터져버립니다. LSP 위반이에요. 이럴 땐 상속 구조를 다시 설계해야 합니다.
// ✅ 공통 행동만 부모로 올리고, 날기는 별도 인터페이스로 분리
public abstract class Bird {
// 모든 새는 먹을 수 있음
public abstract void eat();
}
// 날 수 있는 새만 구현
public interface Flyable {
void fly();
}
public class Sparrow extends Bird implements Flyable {
@Override
public void eat() { System.out.println("참새가 먹이를 먹습니다"); }
@Override
public void fly() { System.out.println("참새가 날아오릅니다"); }
}
public class Penguin extends Bird {
@Override
public void eat() { System.out.println("펭귄이 물고기를 먹습니다"); }
// fly()는 구현 안 해도 됨 → 컴파일 단계에서 안전!
}이제 Bird 타입으로는 eat()만 호출할 수 있고 조류의 공통점이"먹기"라니..., 날 수 있는 새에게만 Flyable 타입을 사용합니다. 런타임 오류가 컴파일 단계에서 원천 차단되죠. 핵심은 "억지로 상속하지 말고, 실제로 공유하는 행동만 묶어라" 입니다.
사실 이 예제는 단순히 LSP 위반을 고치는 것 이상의 의미가 있어요. 처음 설계를 어떻게 잡느냐에 따라 나중에 기능을 추가할 때 코드가 우아하게 확장되기도 하고, 반대로 도미노처럼 와르르 무너지기도 합니다. 코딩을 잘하는 것만큼 아키텍처 설계가 중요한 이유가 바로 이 때문이에요.
"클라이언트가 사용하지 않는 메서드에 의존하면 안 된다."
인터페이스를 너무 크게 만들면, 그 인터페이스를 구현하는 클래스가 필요도 없는 메서드를 억지로 구현해야 합니다. 로봇 예제를 보면 확 와닿을 거예요.
// ❌ 하나의 거대한 인터페이스
public interface Worker {
void work();
void eat();
void sleep();
}
// 로봇은 먹지도 자지도 않는데 억지로 구현해야 함
public class Robot implements Worker {
@Override
public void work() { System.out.println("로봇이 일합니다"); }
@Override
public void eat() {
// 로봇은 밥 안 먹음... 빈 메서드를 억지로 구현 ❌
}
@Override
public void sleep() {
// 로봇은 안 잠... 마찬가지로 억지 구현 ❌
}
}인터페이스를 역할별로 쪼개면 깔끔하게 해결됩니다.
// ✅ 인터페이스를 역할별로 잘게 분리
public interface Workable { void work(); }
public interface Eatable { void eat(); }
public interface Sleepable { void sleep(); }
// 사람은 세 가지 모두 구현
public class Human implements Workable, Eatable, Sleepable {
@Override public void work() { System.out.println("사람이 일합니다"); }
@Override public void eat() { System.out.println("사람이 밥을 먹습니다"); }
@Override public void sleep() { System.out.println("사람이 잠을 잡니다"); }
}
// 로봇은 일하는 것만 구현
public class Robot implements Workable {
@Override
public void work() { System.out.println("로봇이 일합니다"); }
// eat, sleep 구현 불필요 → 깔끔!
}인터페이스가 작고 명확할수록, 클래스는 자신에게 필요한 것만 골라 구현할 수 있습니다. 불필요한 의존성도 사라지고, 코드를 읽는 사람도 한눈에 이 클래스가 무슨 역할을 하는지 파악할 수 있게 되죠.
사실 이 문제는 Java라는 언어의 특성에서 비롯됩니다. Python이나 JavaScript 같은 언어는 "일단 실행해보고 메서드가 없으면 그때 에러 내자"는 유연한 방식을 쓰지만, Java는 다릅니다. 인터페이스를 구현(implements)하겠다고 선언한 순간, 그 안에 적힌 메서드를 전부 구현하지 않으면 컴파일 자체가 안 됩니다. 로봇 예제에서 빈 몸통이나 억지 구현이 생겨난 것도 바로 이 이유예요. ISP는 Java의 이 엄격한 규칙을 오히려 장점으로 활용하는 방법이기도 합니다.
'그럼 프론트엔드에서 ISP를 안 지키면 어떻게 되나요?' 라고 묻는다면, 바로 프론트엔드의 영원한 숙제인 '불필요한 리렌더링' 이 발생합니다.
타입스크립트로 거대한 User 인터페이스를 만들고, 화면에 유저 이름만 보여주는
컴포넌트가 자신이 진짜로 사용하는 딱 그 데이터(name)에만 의존하도록 타입을 쪼개고 분리해 주는 것. 프론트엔드에서도 ISP는 이렇게나 중요하답니다! 자세한 내용은 프론트 운영진에게 ㅎㅎ
// ❌ Bad: ISP 위반 (사용하지 않는 데이터까지 모두 의존)
// 1. 너무 거대한 User 인터페이스
interface User {
id: number;
name: string;
email: string;
address: string;
point: number;
}
// 2. 컴포넌트는 'name'만 필요한데, 'User' 객체 전체를 강제로 받음
interface UserNameProps {
user: User;
}
const UserName = ({ user }: UserNameProps) => {
return <h2>안녕하세요, {user.name} 프론트님!</h2>;
};// ✅ Good: ISP 준수 (딱 필요한 데이터만 의존하도록 분리)
// 1. 컴포넌트가 진짜 그리는 데이터('name')만 인터페이스로 정의
interface UserNameProps {
name: string;
}
const UserName = ({ name }: UserNameProps) => {
return <h2>안녕하세요, {name} 프론트님!</h2>;
};
// 2. 부모 컴포넌트에서 사용할 때는 필요한 짐만 딱 꺼내서 줍니다.
// <UserName name={currentUser.name} />Java 인터페이스 개념이 아직 낯설다면, 영상이 큰 도움이 될 거예요. 🎬
"고수준 모듈이 저수준 모듈에 직접 의존하면 안 된다. 둘 다 추상화(인터페이스)에 의존해야 한다."
'의존성 역전'이라는 이름, 처음엔 좀 무섭게 들릴 수 있어요. 그런데 막상 코드로 보면 생각보다 훨씬 직관적입니다. 아직 Spring을 써보지 않으셨어도 전혀 문제없어요. 오히려 지금 이 원리를 먼저 이해해두면, 나중에 Spring을 배울 때 @Autowired나 @Service 같은 애너테이션이 왜 존재하는지 자연스럽게 납득이 될 거예요. 주문 서비스 예제로 차근차근 살펴볼게요.
// ❌ 고수준 모듈(OrderService)이 저수준 모듈(MySQLDatabase)에 직접 의존
public class MySQLDatabase {
public void save(String data) {
System.out.println("MySQL에 저장: " + data);
}
}
public class OrderService {
// MySQL에 강하게 묶여 있음 → MongoDB로 바꾸려면 OrderService도 수정해야 함 ❌
private MySQLDatabase database = new MySQLDatabase();
public void placeOrder(String order) {
database.save(order);
}
}
// 여기서 '고수준 모듈'이란?
// → 비즈니스 로직이 담긴 클래스예요. 즉, 앱이 실제로 '무슨 일을 하는지'를 다루는 곳.
// → 예제에서는 OrderService (주문을 처리하는 핵심 로직)
//
// '저수준 모듈'이란?
// → 비즈니스 로직을 실행하기 위한 기술적인 도구들이에요.
// → 예제에서는 MySQLDatabase (데이터를 어떻게 저장하는지에 관한 세부 구현)
//
// 문제는 OrderService가 MySQLDatabase를 직접 'new'로 만들어서 쓴다는 거예요.
// 즉, "나는 무조건 MySQL만 쓸 거야"라고 코드에 박아버린 셈입니다.
나중에 MySQL에서 MongoDB로 DB를 교체하려고 하면, 비즈니스 로직이 담긴 OrderService까지 수정해야 합니다. 비즈니스 로직과 DB 기술이 강하게 묶여 있는 거죠. 게다가 단순히 호출 한 줄만 바꾸면 끝나는 게 아니에요. MySQL에만 작동하는 쿼리 문법, 연결 방식, 예외 처리 코드들이 OrderService 곳곳에 스며있기 때문에, DB를 갈아치우면 그것들을 전부 찾아서 함께 교체해야 합니다. 비즈니스 로직을 고치지 않아도 되는 상황인데, DB 교체 때문에 핵심 코드를 통째로 들쑤셔야 하는 상황이 생기는 거예요. 인터페이스를 사이에 끼워 넣으면 이 결합을 깔끔하게 끊을 수 있습니다.
// ✅ 둘 다 인터페이스에 의존 → 구현체를 갈아끼워도 OrderService는 그대로
public interface Database {
void save(String data);
}
public class MySQLDatabase implements Database {
@Override
public void save(String data) {
System.out.println("MySQL에 저장: " + data);
}
}
public class MongoDatabase implements Database {
@Override
public void save(String data) {
System.out.println("MongoDB에 저장: " + data);
}
}
public class OrderService {
private final Database database; // 인터페이스 타입으로 받음
// 생성자 주입 → 스프링에서는 @Autowired가 이걸 자동으로 해줌
public OrderService(Database database) {
this.database = database;
}
public void placeOrder(String order) {
database.save(order); // MySQL이든 MongoDB든 상관없음
}
}
// 실행 시 원하는 구현체를 꽂아줌
public class Main {
public static void main(String[] args) {
Database db = new MySQLDatabase(); // 여기만 바꾸면 됨
OrderService service = new OrderService(db);
service.placeOrder("아이폰 1대");
}
}이제 DB를 교체하고 싶으면 Main에서 구현체만 바꿔 꽂으면 됩니다. OrderService는 손댈 필요가 없어요. 스프링에서는 이 "구현체를 꽂아주는" 과정을 IoC 컨테이너가 @Autowired로 자동으로 처리해줍니다.
SOLID 원칙을 요약하면 이렇습니다.
- S (단일 책임): 클래스는 바뀌는 이유가 하나여야 한다.
- O (개방-폐쇄): 새 기능은 기존 코드를 건드리지 않고 추가해야 한다.
- L (리스코프 치환): 자식 클래스는 부모를 완전히 대체할 수 있어야 한다.
- I (인터페이스 분리): 사용하지 않는 메서드엔 의존하지 않아야 한다.
- D (의존성 역전): 구체적인 것보다 추상적인 것에 의존해야 한다.
마지막으로 꼭 당부하고 싶은 한 가지가 있습니다. 이 원칙들은 우리를 옭아매는 족쇄가 아니라, 길을 잃었을 때 꺼내보는 '나침반' 이라는 점이에요.
당장 다음 프로젝트부터 이 5가지를 완벽하게 지키려고 하면 오히려 코딩이 숨 막히게 느껴질 수 있습니다. 원칙을 교조적으로 따르기보다, "지금 내 코드에서 가장 자주 바뀔 만한 곳이 어디지?" 를 고민하며 상황에 맞게 하나씩 적용하다보면 똑똑한 여러분들은 금방 감을 잡을 수 있을 거에요. 좋은 설계란 완벽한 원칙 준수가 아니라, 변화에 유연하게 대처할 수 있는 코드를 만드는 것이니까요.
좋은 설계에는 프론트엔드와 백엔드의 구분이 없습니다. 화면의 UI 컴포넌트를 조립하든, 서버의 비즈니스 로직을 짜든 결국 '나중에 고치기 쉬운 코드' 를 향한 고민의 본질은 똑같거든요. 결국 SOLID는 우리가 어떤 포지션에 있든 상관없이, 튼튼하고 좋은 설계를 하기 위한 가장 펀더멘탈한 이론입니다. 이 원칙을 이해하는 것만으로도 여러분의 개발 기본기는 이미 한 단계 도약한 셈이에요.
사실, 원칙끼리 서로 충돌해서 '이러지도 저러지도 못하는' 딜레마 상황도 꽤 발생합니다. 이 부분까지 깊게 파고들고 싶었지만 글이 너무 길어졌네요😅 만약 오늘 내용이 아기사자 여러분의 긍정적인 반응으로 이어진다면, 다음번엔 "SOLID 원칙 대충돌! 현실적인 타협안 찾기" 파트 2로 돌아오겠습니다.
다가올 해커톤과 프로젝트에서 버그와 씨름하다가 문득 오늘 배운 내용이 떠오르길 바라며! 궁금한 점이 있거나, "제 코드는 어떻게 고쳐야 할까요?" 같은 고민이 있다면 언제든 디스코드 #99-help-me 채널을 이용해주세요~
끝까지 읽어주셔서 감사합니다.
댓글 0