한 줄 정의
SOLID는 객체지향 코드를 변경하기 쉽고 서로 덜 얽히게 만드는 다섯 가지 설계 원칙이다.
SOLID는 무조건 클래스와 인터페이스를 늘리는 규칙이 아니다. 코드에서 반복해서 불편한 변경이 생길 때, 책임과 의존 관계를 점검하는 질문에 가깝다.
왜 필요한가
작은 게임은 Player 하나가 공격, 저장, 화면 출력, 사운드 재생을 모두 맡아도 동작한다. 하지만 무기와 캐릭터가 늘어나면 작은 변경이 여러 파일로 번지고, 한 기능을 고쳤는데 다른 기능이 깨지며, 단위 테스트를 위해 게임 전체를 실행해야 하는 문제가 생긴다.
SOLID는 이런 신호를 발견하고 코드를 역할별로 나누고, 확장 지점을 만들고, 교체 가능한 관계로 설계하도록 돕는다.
다섯 원칙 한눈에 보기
원칙 | 한 문장 | 예시 |
|---|---|---|
S — 단일 책임 | 한 클래스에는 변경 이유가 하나여야 한다. |
|
O — 개방·폐쇄 | 기능 추가에는 열고 기존 코드 수정에는 닫는다. | 새로운 몬스터(고블린, 오크)를 추가해도 기존 전투 코드는 수정하지 않는다. |
L — 리스코프 치환 | 자식은 부모가 한 약속을 지켜야 한다. | 모든 몬스터는 |
I — 인터페이스 분리 | 사용하지 않는 기능에 의존하게 하지 않는다. | NPC는 |
D — 의존성 역전 | 구체 클래스보다 역할을 나타내는 추상화에 의존한다. |
|
S — 단일 책임 원칙
단일 책임 원칙(Single Responsibility Principle)은 “클래스가 하는 일이 하나뿐이어야 한다”보다 클래스를 변경하게 만드는 이유가 하나여야 한다는 뜻에 가깝다.
Player가 공격 규칙, 파일 저장, 화면 출력까지 맡으면 저장 형식만 바뀌어도 플레이어 코드를 수정해야 한다. Player는 플레이어 행동, GameSaver는 저장, PlayerRenderer는 화면 출력을 맡도록 나누면 변경 영향이 해당 역할에 머문다.
먼저 여러 책임이 한 클래스에 섞인 코드다.
나쁜 예
class Player {
void attack() { /* 전투 처리 */ }
void saveGame() { /* 파일 저장 */ }
void draw() { /* 화면 출력 */ }
}
각 책임을 별도 클래스로 나누면 변경 이유도 분리된다.
좋은 예
class Player {
void attack() { /* 전투 처리 */ }
}
class GameSaver {
void save(Player player) { /* 파일 저장 */ }
}
class PlayerRenderer {
void draw(Player player) { /* 화면 출력 */ }
}
O — 개방·폐쇄 원칙
개방·폐쇄 원칙(Open-Closed Principle)은 새 기능은 추가할 수 있지만 이미 검증한 코드는 가능한 한 건드리지 않는 구조를 뜻한다.
무기 종류를 문자열로 받아 분기하면 지팡이나 도끼가 생길 때마다 기존 공격 코드를 수정해야 한다.
나쁜 예
void attack(String type) {
if (type.equals("검")) {
System.out.println("검으로 공격");
} else if (type.equals("활")) {
System.out.println("활을 발사");
}
// 새 무기가 생길 때마다 이 메서드를 수정해야 한다.
}
공통 역할을 Weapon으로 표현하고 구현 클래스를 파일별로 나누면 새 무기는 클래스를 추가하는 방식으로 확장할 수 있다.
좋은 예
interface Weapon { //기본 베이스가 되는 무기를 만든다.
void attack();
}
class Sword implements Weapon { //무기를 기반으로 검을 만든다.
public void attack() {
System.out.println("검으로 공격");
}
}
class Bow implements Weapon { //무기를 기반으로 활을 만든다.
public void attack() {
System.out.println("활을 발사");
}
}
class Player {
void attack(Weapon weapon) {
weapon.attack();
}
}
예를 들어 지팡이를 추가하려면 Staff.java만 새로 만들면 된다. Weapon.java, Sword.java, Bow.java, Player.java는 수정하지 않는다.
class Staff implements Weapon {
public void attack() {
System.out.println("마법을 사용");
}
}
개방·폐쇄 원칙의 핵심은 “기존 파일은 절대 수정하지 않는다”가 아니다. 자주 추가되는 종류를 기존 조건문 수정이 아니라 새 구현 추가로 확장할 수 있게 만드는 것이다.
L — 리스코프 치환 원칙
리스코프 치환 원칙(Liskov Substitution Principle)은 부모 타입을 기대하는 자리에 자식 객체를 넣어도 프로그램의 약속이 깨지지 않아야 한다는 뜻이다.
게임에서 공격하면 부서지는 석상을 만들고 싶다고 가정하자. 석상에 체력만 재사용하려고 Character를 상속하면, 석상은 필요하지 않은 이동 기능까지 물려받는다.
아래 코드에서 Character는 모든 자식이 이동할 수 있다고 약속한다.
나쁜 예
class Character {
protected int hp = 100; //캐릭터에겐 체력이 있다.
void move() { //캐릭터는 이동도 가능하다.
System.out.println("캐릭터가 이동");
}
void takeDamage(int damage) { //데미지를 입을수도 있다.
hp -= damage;
}
}
Statue는 체력 때문에 캐릭터를 상속했지만 이동할 수 없으므로 부모의 약속을 깨뜨린다.
class Statue extends Character { //체력은 생겼지만 이동 함수를 따로 처리해야한다.
@Override
void move() {
throw new UnsupportedOperationException("석상은 이동할 수 없음");
}
}
Character를 받는 코드는 어떤 자식이 들어와도 move()가 정상 동작한다고 기대한다. 하지만 석상으로 교체하면 실행 중 오류가 발생하므로 리스코프 치환 원칙을 위반한다.
void startMove(Character character) {
character.move();
}
//실수로 석상을 넣음.
startMove(new Statue()); // 석상은 움직일수 없어서 실행 중 오류
해결 방법은 체력과 피해 처리를 별도의 역할인 Damageable로 분리하는 것이다.
좋은 예
interface Damageable { //데미지 전용 코드
void takeDamage(int damage);
int getHp();
}
class Character implements Damageable { //체력 데미지 전용 코드를 이용
private int hp = 100;
void move() {
System.out.println("캐릭터가 이동");
}
public void takeDamage(int damage) {
hp -= damage;
}
public int getHp() {
return hp;
}
}
class Statue implements Damageable { //석상은 체력하고 데미지 처리만 받음.
private int hp = 300;
public void takeDamage(int damage) {
hp -= damage;
}
public int getHp() {
return hp;
}
}
공격 코드는 캐릭터 여부가 아니라 피해를 받을 수 있는지에만 의존한다. 캐릭터와 석상 모두 Damageable의 약속을 지키므로 어느 쪽으로 교체해도 정상 동작한다.
class Battle { //공격 하는 코드
void attack(Damageable target, int damage) {
target.takeDamage(damage);
}
}
public class Main {
public static void main(String[] args) {
Character warrior = new Character();
Statue statue = new Statue();
Battle battle = new Battle();
System.out.println("=== 공격 전 ===");
System.out.println("전사 HP: " + warrior.getHp());
System.out.println("석상 HP: " + statue.getHp());
System.out.println("\n=== 공격 ===");
System.out.println("전사가 25의 데미지를 받습니다.");
System.out.println("석상이 80의 데미지를 받습니다.");
battle.attack(warrior, 25);
battle.attack(statue, 80);
System.out.println("\n=== 공격 후 ===");
System.out.println("전사 HP: " + warrior.getHp());
System.out.println("석상 HP: " + statue.getHp());
}
}
=== 공격 전 ===
전사 HP: 100
석상 HP: 300
=== 공격 ===
전사가 25의 데미지를 받습니다.
석상이 80의 데미지를 받습니다.
=== 공격 후 ===
전사 HP: 75
석상 HP: 220
체력을 가졌다고 모두 캐릭터인 것은 아니다. 상속은 단순한 코드 재사용이 아니라 “자식은 부모로 교체할 수 있다”는 약속이므로, 공통 데이터만 필요하다면 별도의 역할이나 구성 요소로 분리한다.
I — 인터페이스 분리 원칙
인터페이스 분리 원칙(Interface Segregation Principle)은 큰 만능 인터페이스 하나보다 사용하는 쪽에 맞춘 작은 인터페이스를 만들라는 뜻이다.
GameCharacter에 walk(), fly(), swim(), useMagic()을 모두 넣으면 전사는 비행과 마법 메서드까지 억지로 구현해야 한다. Walkable, Flyable, MagicUser로 나누면 전사는 걷기만, 드래곤은 걷기와 비행만 선택할 수 있다.
큰 인터페이스는 구현하지 못하는 기능까지 강요한다.
나쁜 예
interface GameCharacter { //캐릭터는 걷고 날고 마법 쓸수 있다.
void walk();
void fly();
void useMagic();
}
class Warrior implements GameCharacter { //전사는 걷기만 가능하다.
public void walk() { System.out.println("걷기"); }
public void fly() { throw new UnsupportedOperationException(); } //쓸모없음
public void useMagic() { throw new UnsupportedOperationException(); } //쓸모없음
}
인터페이스를 역할별로 나누면 필요한 기능만 선택할 수 있다.
좋은 예
interface Walkable { //걷기 따로 분리
void walk();
}
interface Flyable { //날기 따로 분리
void fly();
}
interface MagicUser { // 마법 사용 따로 분리
void useMagic();
}
class Warrior implements Walkable { //전사는 걷기만 받는다.
public void walk() {
System.out.println("전사가 걷기");
}
}
class Dragon implements Walkable, Flyable { // 드래곤은 걷기도 하고 날수도 있다.
public void walk() { System.out.println("드래곤이 걷기"); }
public void fly() { System.out.println("드래곤이 비행"); }
}
D — 의존성 역전 원칙
의존성 역전 원칙(Dependency Inversion Principle)은 핵심 정책이 세부 구현을 직접 선택하지 않고, 양쪽 모두 인터페이스 같은 추상화에 의존하게 만드는 원칙이다.
Player 내부에서 new Sword()를 만들면 플레이어와 검이 강하게 묶인다. 생성자에서 Weapon을 받으면 검, 활, 지팡이, 테스트용 가짜 무기를 자유롭게 교체할 수 있다. 외부에서 필요한 객체를 전달하는 이 방식을 의존성 주입(Dependency Injection)이라고 한다.

앞에서 파일별로 나눈 무기 예제를 의존성 주입까지 적용한 최종 구조는 다음과 같다.
solid-game/
├── Weapon.java
├── Sword.java
├── Bow.java
├── Staff.java
├── Player.java
└── Main.java
interface Weapon {
void attack();
}
class Sword implements Weapon {
public void attack() {
System.out.println("검으로 공격");
}
}
class Bow implements Weapon {
public void attack() {
System.out.println("활을 발사");
}
}
class Staff implements Weapon {
public void attack() {
System.out.println("마법을 사용");
}
}
class Player {
private final Weapon weapon;
Player(Weapon weapon) {
this.weapon = weapon;
}
void attack() {
weapon.attack();
}
}
public class Main {
public static void main(String[] args) {
Player warrior = new Player(new Sword());
Player archer = new Player(new Bow());
Player wizard = new Player(new Staff());
warrior.attack();
archer.attack();
wizard.attack();
}
}
검으로 공격
활을 발사
마법을 사용
Player는 어떤 무기를 받았는지 확인하는 조건문이 없다. Weapon의 attack() 약속만 사용하므로 실제 무기 구현을 생성 시점에 교체할 수 있다.
서로 어떻게 연결되는가
다섯 원칙은 따로 외우는 규칙이 아니다. S와 I로 책임과 역할을 작게 나누고, L로 그 역할의 약속을 지키며, D로 역할에 의존하면, 결과적으로 O처럼 기존 코드를 덜 고치고 기능을 확장하기 쉬워진다.
무기 예제에서도 Weapon은 작은 역할이고(I), 모든 무기는 attack() 약속을 지키며(L), Player는 구체 무기 대신 그 역할에 의존한다(D). 그래서 새 무기를 추가해도 Player를 수정하지 않는다(O).
언제 적용해야 할까
한 클래스가 여러 팀이나 기능의 요구 때문에 자주 바뀔 때
새 종류를 추가할 때마다 같은 조건문을 수정할 때
상속한 기능을 일부 자식이 제대로 제공하지 못할 때
인터페이스를 구현하면서 쓰지 않는 메서드가 생길 때
테스트에서 실제 파일, DB, 네트워크 객체를 교체하기 어려울 때
반대로 한 번 쓰고 버릴 짧은 프로그램에 인터페이스와 클래스를 미리 많이 만들면 구조만 복잡해질 수 있다. 현재 보이는 변경 신호를 해결할 만큼만 적용하고, 미래의 모든 가능성을 추측해 설계하지 않는다.
SOLID 최종 정리
S: 한 클래스가 바뀌는 이유를 하나로 만든다.
O: 새 기능은 기존 코드 수정보다 새 구현 추가로 확장한다.
L: 자식은 부모 타입의 약속을 깨지 않는다.
I: 사용하지 않는 기능을 강요하지 않도록 인터페이스를 작게 나눈다.
D: 구체적인 구현보다 역할을 나타내는 추상화에 의존한다.
외울 문장: 책임은 작게, 확장은 새 구현으로, 약속은 지키고, 인터페이스는 필요한 만큼, 의존은 역할을 향하게.
관련 문서
객체지향(OOP) 용어: 클래스, 객체, 상속, 다형성의 기초를 먼저 익힌다.
절차지향 vs 객체지향 프로그래밍: 두 프로그래밍 방식의 사고법과 선택 기준을 비교한다.
댓글 0
댓글을 불러오는 중…