개발을 계속하다 보니 SOLID 원칙의 필요성을 체감함과 동시에, 내가 제대로 알고 있나라는 생각이 들어 정리하려고 합니다.
Unity 엔진 환경에서 게임 개발을 하고 있어 해당 환경 기반으로 정리하겠습니다.
그리고 각 원칙마다 제 경험도 같이 덧붙이려고 합니다.
SOLID 원칙이란
소프트웨어 설계의 5가지 핵심 기본 사항을 나타내는 약어입니다.
각 사항은 객체지향설계를 견고하고 유지보수하기 쉽게 하기 위한 원칙이며 소프트웨어의 재사용성, 유연성, 확장성을 높이는 데 도움이 됩니다.
각 핵심 기본 사항들은 아래와 같습니다.
- SRP : 단일 책임 원칙 (Single Responsibility Principle)
- OCP : 개방 - 폐쇄 원칙 (Open / Closed Principle)
- LSP : 리스코프 치환 원칙 (Liskov Substitution Principle)
- ISP : 인터페이스 분리 원칙 (Interface Segregation Principle)
- DIP : 의존관계 역전 원칙 (Dependency Inversion Principle)
각 원칙들을 이제 차례대로 설명해 보도록 하겠습니다.
1. SRP : 단일 책임 원칙 (Single Responsibility Principle)
각 모듈, 클래스, 함수는 하나의 책임만 져야 한다는 원칙입니다.
설계할 때 하나의 큰 단위로 구현하는 것이 아닌 여러 개의 작은 단위가 유기적으로 소통하도록 분리해야 하며, 단위의 기준은 한 단위의 기능 또는 역할이어야 합니다.
Player에 Audio, Input, Movement 기능을 구현한다는 예시로 비교해 보겠습니다.
단일 책임 원칙을 무시하고 구현했을 때 Player 클래스는 아래 그림과 같이 하나의 클래스로 Audio, Input, Movement 각 기능을 구현합니다.

코드로 작성할 경우 아래와 같습니다.
public class Player : MonoBehaviour
{
[SerializeField]
private string inputAxisName;
[SerializeField]
private float positionMultiplier;
private float yPosition;
private AudioSource bounceSfx;
private void Start()
{
bounceSfx = GetComponent<AudioSource>();
}
private void Update()
{
float delta = Input.GetAxis(inputAxisName) * Time.deltaTime;
yPosition = Mathf.Clamp(yPosition + delta, -1, 1);
transform.position =
new Vector3(transform.position.x, yPosition * positionMultiplier, transform.position.z);
}
private void OnTriggerEnter(Collider other)
{
bounceSfx.Play();
}
}
위의 Player를 단일 책임 원칙을 지키면서 구현한다면 아래 그림과 같이 Audio, Input, Movement 각 기능 단위로 클래스로 만들고, Player는 해당 클래스들을 사용하게 됩니다.

코드로 구현한다면 아래와 같습니다.
public class Player : MonoBehaviour
{
[SerializeField]
private PlayerInput playerInput;
[SerializeField]
private PlayerMovement playerMovement;
[SerializeField]
private PlayerAudio playerAudio;
private void Start()
{
playerAudio = GetComponent<PlayerAudio>();
playerInput = GetComponent<PlayerInput>();
playerMovement = GetComponent<PlayerMovement>();
}
private void Update()
{
var deltaTime = Time.deltaTime;
var moveDelta = playerInput.GetMoveDelta(deltaTime);
playerMovement.Move(deltaTime);
}
private void OnTriggerEnter(Collider other)
{
playerAudio.Play();
}
}
public class PlayerInput : MonoBehaviour
{
[SerializeField]
private string inputAxisName;
public float GetMoveDelta(float deltaTime)
{
return Input.GetAxis(inputAxisName) * deltaTime;
}
}
public class PlayerMovement : MonoBehaviour
{
[SerializeField]
private float positionMultiplier;
private float yPosition;
public void Move(float deltaTime)
{
yPosition = Mathf.Clamp(yPosition + deltaTime, -1, 1);
transform.position =
new Vector3(transform.position.x, yPosition * positionMultiplier, transform.position.z);
}
}
public class PlayerAudio : MonoBehaviour
{
[SerializeField]
private AudioSource bounceSfx;
public void Play()
{
bounceSfx.Play();
}
}
해당 원칙을 지킴으로써 각 모듈, 클래스, 함수는 명확하고 한정된 역할을 수행하게 되어 코드의 가독성과 유지보수성이 향상됩니다. 또한 새로운 기능을 추가하거나 변경할 때 다른 곳에 영향을 주지 않으므로 코드의 안정성과 확장성도 높아지게 됩니다.
다만, 극단적으로 간략하게 작성할 경우 오히려 가독성이 떨어지고, 작업할 때 효율성이 떨어지므로 주의해야 합니다.
💬 내 생각
개인적으로 다섯 원칙 중 가장 중요하다고 생각하는 원칙이다.
프로젝트가 커지면서 개발 속도는 점점 느려지기 마련인데, 해당 원칙을 잘 지킬수록 느려지는 정도가 많이 줄어들고 특히 팀원과 협업할 때 서로의 영역을 침범하지 않아 더 원활하게 작업이 진행되었다.
다만, 쪼개는 단위가 너무 작을 경우 작업 속도가 너무 느려지고 굳이 클래스나 함수가 나눠지지 않아도 되는데 나누게 되면, 한눈에 들어오는 코드 정보가 너무 작아져 가독성을 많이 해치게 되어 적당히가 참 중요한 것 같다.
2. OCP : 개방 - 폐쇄 원칙 (Open / Closed Principle)
클래스가 확장에는 개방적이되, 변경에는 폐쇄적이어야 한다는 원칙입니다.
클래스에 기능을 추가할 때는 기존 동작을 수정하는 것이 아닌 새로운 동작을 추가하는 것으로 구현해야 하며, 클래스 설계도 이렇게 구현할 수 있도록 되어있어야 합니다.
AreaCalculator를 구현하는 예시로 비교해보겠습니다.
개방 폐쇄 원칙을 무시하고 구현했을 때 아래 그림과 같이 AreaCalculator 클래스 안에 각 영역을 구하는 함수를 구현합니다.

코드로 작성할 경우 아래와 같습니다.
public class AreaCalculator
{
public float GetRectangleArea(Rectangle rectangle)
{
return rectangle.width * rectangle.height;
}
public float GetCircleArea(Circle circle)
{
return circle.radius * circle.radius * Mathf.PI;
}
}
public class Rectangle
{
public float width;
public float height;
}
public class Circle
{
public float radius;
}
위의 AreaCalculator를 개방 폐쇄 원칙을 지키면서 구현한다면 아래 그림과 같이 Shape 라는 추상클래스와 그 안에 CalculateArea 추상 함수를 구현하고 자식인 각 영역 클래스에 CalculateArea 함수를 구현하게 됩니다. 그리고 AreaCalculator 는 해당 함수를 사용하여 각 영역을 계산합니다.

코드로 구현한다면 아래와 같습니다.
public class AreaCalculator
{
public float GetArea(Shape shape)
{
return shape.CalculateArea();
}
}
public abstract class Shape
{
public abstract float CalculateArea();
}
public class Rectangle : Shape
{
public float width;
public float height;
public override float CalculateArea()
{
return width * height;
}
}
public class Circle : Shape
{
public float radius;
public override float CalculateArea()
{
return radius * radius * Mathf.PI;
}
}
해당 원칙을 지킴으로써 기존에 작성된 코드의 변경 없이 새로운 코드를 작성하는 것으로 기능을 추가할 수 있고, 추가하고 나서 기존 코드를 확인하지 않아도 됩니다. 더 나아가 기존 코드의 변경으로 인한 버그 발생을 피할 수 있습니다.
💬 내 생각
이 원칙은 코드가 쌓이면 쌓일수록 안 지킬 때보다 지킬 때 확장하기가 훨씬 쉬워, 빛을 발하는 원칙이라 생각한다.
다만, 처음 기능을 추가할 때 확장성이 없는 기능인 경우에 해당 원칙을 지키게 되면, 작업 속도도 많이 느리고, 추후에 기능을 확장할 때 설계를 갈아엎는 경우도 빈번해서 기능의 종류가 두세 가지인 경우부터 원칙을 지키는 게 제일 효율적이라고 생각한다.
3. LSP : 리스코프 치환 원칙 (Liskov Substitution Principle)
자식클래스가 언제나 부모클래스를 대체할 수 있어야 한다는 원칙입니다.
자식클래스는 부모클래스를 상속받아서 부모의 기능을 모두 할 수 있어야 하기 때문에 부모클래스와 똑같은 요청에 똑같은 응답을 할 수 있어야 하며, 응답의 타입 또한 같아야 합니다.
Vehicle 부모 클래스로, Car 와 Train 을 자식 클래스로 구현하는 예시로 비교해 보겠습니다.
리스코프 치환 원칙을 무시하고 Vehicle 을 구현했을 때 아래 그림과 같이 상단 함수(`GoForward`, `Reverse`)는 자식클래스와 호환이 되지만, 하단 함수(`TurnRight`, `TurnLeft`)는 기차에서는 구현할 수 없습니다. (기차는 철로를 따라가기 때문에 좌회전, 우회전을 자유롭게 못하기 때문에) 때문에 기차에서는 동작하지 않도록 둬야 합니다.


코드로 구현한다면 아래와 같습니다.
public class Vehicle
{
public float speed = 100;
public Vector3 direction;
public void GoForward()
{
//전진
}
public void Reverse()
{
//후진
}
public void TurnRight()
{
//우회전
}
public void TurnLeft()
{
//좌회전
}
}
public class Car
{
//Vehicle 동작과 똑같으므로 그대로 두면 됨
}
public class Train
{
//Vehicle 동작다르게 좌회전 우회전 안하므로 재정의
public new void TurnRight()
{
//우회전 안함
}
public new void TurnLeft()
{
//좌회전 안함
}
}
리스코프 치환 원칙을 지킨다면 Vehicle 을 각 Car 와 Train에 맞게 RoadVehicle, RailVehicle 로 분리하는 게 좋으며, 인터페이스를 만들어 해당 기능들을 호출할 수 있게 해야합니다.

코드로 구현한다면 다음과 같습니다.
public interface ITurnable
{
public void TurnRight();
public void TurnLeft();
}
public interface IMovable
{
public void GoForward();
public void Reverse();
}
public class RoadVehicle : IMovable, ITurnable
{
public float speed = 100f;
public float turnSpeed = 5f;
public virtual void GoForward()
{
//전진
}
public virtual void Reverse()
{
//후진
}
public virtual void TurnLeft()
{
//좌회전
}
public virtual void TurnRight()
{
//우회전
}
}
public class RailVehicle : IMovable
{
public float speed = 100;
public virtual void GoForward()
{
//전진
}
public virtual void Reverse()
{
//후진
}
}
public class Car : RoadVehicle
{
//RoadVehicle 동작과 똑같으므로 그대로 두면 됨
}
public class Train : RailVehicle
{
//RailVehicle 동작과 똑같으므로 그대로 두면 됨
}
해당 원칙을 지킴으로써, 자식 클래스를 부모 클래스 타입으로 사용할 때 예측할 수 없는 방식으로 사용되는 것이 아닌 동일한 방식으로 사용할 수 있습니다. 때문에 상속받은 메소드를 재정의할 때 기존의 동작을 보존하는 것이 중요합니다.
💬 내 생각
프로그래밍을 처음 배울 때 이 원칙을 이해하기 너무나 힘들었고, 때문에 의도적으로 이 원칙을 지켜야 한다고 생각하면서 개발을 하지는 않았었다.
하지만 계속 프로그래밍을 하면서 상속을 남용하여 쓰기보다는 인터페이스를 활용하여 최대한 자제하면서 써야한다는 생각을 자연스럽게 하게 되었는데, 알고보니 이 생각이 리스코프 치환 원칙을 지키려고 노력하고 있다는 걸 깨닫게 되었다.
4. ISP : 인터페이스 분리 원칙 (Interface Segregation Principle)
클래스는 자신이 사용하지 않는 메소드에 강제로 종속되지 않아야 한다는 원칙입니다.
다시 말해서 인터페이스의 크기를 조절해서 상속받은 클래스가 자기가 사용하지 않는 메소드를 인터페이스 때문에 구현하지 말아야 합니다.
유닛의 상태를 인터페이스로 구현하는 것으로 예시 비교해보겠습니다.
원칙을 지키지 않았을 때 유닛의 상태는 아래 그림과 같이 하나의 큰 IUnitStats 로 구현합니다. 이렇게 구현하면 유닛은 해당 프로퍼티나 함수들을 대부분 사용하겠지만, 파괴가능한 프랍을 만들 경우 해당 인터페이스를 상속받으면 대부분의 프로퍼티나 함수들을 사용하지 않을겁니다.

코드로 구현한다면 다음과 같습니다.
public interface IUnitStats
{
public float Health { get; set; }
public int Defense { get; set; }
public void Die();
public void TakeDamage();
public void RestoreHealth();
public float MoveSpeed { get; set; }
public float Acceleration { get; set; }
public void GoForward();
public void Reverse();
public void TurnLeft();
public void TurnRight();
public int Strength { get; set; }
public int Dexterity { get; set; }
public int Endurance { get; set; }
}
public class EnemyUnit : IUnitStats
{
public float Health { get; set; }
public int Defense { get; set; }
public void Die()
{
//죽음
}
public void TakeDamage()
{
//피해 입음
}
public void RestoreHealth()
{
//체력 회복
}
public float MoveSpeed { get; set; }
public float Acceleration { get; set; }
public void GoForward()
{
//전진
}
public void Reverse()
{
//후진
}
public void TurnLeft()
{
//좌회전
}
public void TurnRight()
{
//우회전
}
public int Strength { get; set; }
public int Dexterity { get; set; }
public int Endurance { get; set; }
}
public class ExplodingBarrel : IUnitStats
{
#region 인터페이스 상속
#region 사용함
public float Health { get; set; }
public int Defense { get; set; }
public void Die()
{
//죽음
}
public void TakeDamage()
{
//피해 입음
}
public void RestoreHealth()
{
//체력 회복
}
#endregion
#region 사용 안함
public float MoveSpeed { get; set; }
public float Acceleration { get; set; }
public void GoForward()
{
//전진
}
public void Reverse()
{
//후진
}
public void TurnLeft()
{
//좌회전
}
public void TurnRight()
{
//우회전
}
public int Strength { get; set; }
public int Dexterity { get; set; }
public int Endurance { get; set; }
#endregion
#endregion
#region 인터페이스 상속 안받음
public float Mass { get; set; }
public int ExplosiveForce { get; set; }
public int FuseDelay { get; set; }
public void Explode()
{
//폭발
}
#endregion
}
인터페이스 원칙을 지킨다면 하나의 큰 IUnitStat 인터페이스를 만드는 대신 필요한 액션들만 가진 여러 인터페이스(IMoveable, IUnitStats, IDamageable)를 만들어서 상속받는 클래스가 필요없는 함수를 구현할 필요 없게 만들어야 합니다.
또한, IExplodable 를 추가하여 ExplodingBarrel 뿐만 아니라 다른 폭발하는 프랍들도 추가하기 용이하게 만듭니다.

코드로 작성하면 아래와 같습니다.
public interface IMovable
{
public float MoveSpeed { get; set; }
public float Acceleration { get; set; }
public void GoForward();
public void Reverse();
public void TurnLeft();
public void TurnRight();
}
public interface IDamageable
{
public float Health { get; set; }
public int Defense { get; set; }
public void Die();
public void TakeDamage();
public void RestoreHealth();
}
public interface IUnitStats
{
public int Strength { get; set; }
public int Dexterity { get; set; }
public int Endurance { get; set; }
}
public interface IExplodable
{
public float Mass { get; set; }
public float ExplosiveForce { get; set; }
public float FuseDelay { get; set; }
public void Explode();
}
public class EnemyUnit : IMovable, IDamageable, IUnitStats
{
#region IMovable 인터페이스 상속
public float MoveSpeed { get; set; }
public float Acceleration { get; set; }
public void GoForward()
{
//전진
}
public void Reverse()
{
//후진
}
public void TurnLeft()
{
//좌회전
}
public void TurnRight()
{
//우회전
}
#endregion
#region IDamageable 인터페이스 상속
public float Health { get; set; }
public int Defense { get; set; }
public void Die()
{
//죽음
}
public void TakeDamage()
{
//피해 입음
}
public void RestoreHealth()
{
//체력 회복
}
#endregion
#region IUnitStats 인터페이스 상속
public int Strength { get; set; }
public int Dexterity { get; set; }
public int Endurance { get; set; }
#endregion
}
public class ExplodingBarrel : IDamageable, IExplodable
{
#region IDamageable 인터페이스 상속
public float Health { get; set; }
public int Defense { get; set; }
public void Die()
{
//죽음
}
public void TakeDamage()
{
//피해 입음
}
public void RestoreHealth()
{
//체력 회복
}
#endregion
#region IExplodable 인터페이스 상속
public float Mass { get; set; }
public int ExplosiveForce { get; set; }
public int FuseDelay { get; set; }
public void Explode()
{
//폭발
}
#endregion
}
해당 원칙을 지킴으로써 각 클래스를 독립적으로 유지하고, 수정 및 확장을 용이하게 할 수 있습니다.
💬 내 생각
인터페이스가 너무 클 경우 상속받은 클래스가 불필요한 함수를 가질 뿐만 아니라 잘못 설계된 인터페이스를 수정할 때도 꽤 리소스가 많이 들어가 불편함을 겪었었던 경험이 있다.
하지만 이 원칙을 너무 신경쓰면서 구조를 짜다보면 인터페이스 구현 난이도가 너무 올라가서 개인적으로는 인터페이스 위주의 구조를 짜면서 좀 더 연습해야겠다 생각이 든 원칙이다.
5. DIP : 의존관계 역전 원칙 (Dependency Inversion Principle)
상위 모듈이 하위 모듈에 직접적으로 의존하면 안되고, 양쪽 모두 추상화에 의존해야 한다는 원칙입니다.
클래스 간 종속성을 가능한 최소화하도록 하는것이 목적이고, 추상화를 통해 상위모듈과 하위모듈을 연결합니다.
문을 여는 로직을 Switch 클래스와 Door 클래스를 통해 구현하는 것으로 예시 비교해보겠습니다.
의존관계 역전 원칙을 지키지 않늗다면 Switch 클래스는 Door 클래스의 Open, Close 함수를 직접 사용하여 구현하게 되는데, Door 클래스의 구현부가 바뀌거나 다른 하위 모듈을 추가하게 될 때 Switch 클래스도 같이 수정하게 되므로 오류가 발생할 가능성이 커집니다.

코드로 구현한다면 다음과 같습니다.
public class Switch : MonoBehaviour
{
public Door door;
public bool isActivated;
public void Toggle()
{
if (isActivated)
{
isActivated = false;
door.Close();
}
else
{
isActivated = true;
door.Open();
}
}
}
public class Door : MonoBehaviour
{
public void Open()
{
//문 열림
}
public void Close()
{
//문 닫힘
}
}
의존관계 역전 원칙을 지킨다면, Switch 클래스와 Door 클래스의 연결을 ISwichable 인터페이스를 이용해서 구현합니다.
이렇게 함으로써 Door 클래스의 구현부가 바뀌어도 ISwichable 가 바뀔 정도의 변화가 아니면 Switch 클래스는 신경 안써도 되고, 다른 하위 모듈을 추가할 때도 ISwitchable 을 통해 연결되므로 쉽게 추가가 가능합니다.

코드로 구현한다면 아래와 같습니다.
public class Switch : MonoBehaviour
{
public ISwitchable client;
public void Toggle()
{
if (client.IsActive)
{
client.Deactivate();
}
else
{
client.Activate();
}
}
}
public interface ISwitchable
{
public bool IsActive { get; }
public void Activate();
public void Deactivate();
}
public class Door : MonoBehaviour, ISwitchable
{
private bool isActive;
public bool IsActive => isActive;
public void Activate()
{
isActive = true;
//문 열림
}
public void Deactivate()
{
isActive = false;
//문 닫힘
}
}
해당 원칙을 지킴으로써 클래스 간 결합도를 줄여 다른 클래스가 수정되어도 영향을 최소로 미치게 만들어서 유지보수성을 올려줍니다. 또한, 확장을 할 때도 추가 작업을 덜어주어 작업 효율성까지 올려줍니다.
다만, 연결고리가 되는 인터페이스나 추상클래스의 수정이나 기능 추가를 하게 될 경우에는 다른 코드에 영향을 많이 미치기 때문에 신경을 많이써서 설계해야합니다.
💬 내 생각
의존성 주입을 공부하고 사용해보면서 해당 원칙을 자연스럽게 알게된 것 같다. 확실히 코드를 짤 때 다른 코드에 영향을 덜 주는 코드를 강제하게 만들어 유지보수성이 많이 오른 경험이 있다.
해당 원칙을 사용할 때 쓰는 방법이 인터페이스, 추상클래스 두가지가 있는데 인터페이스를 사용할 때는 수정 가능성이 거의 없고 간단할 때 많이 쓰는 편이고, 추상클래스는 수정가능성이 많거나, 공용으로 쓰는 함수들이 많아 부모클래스에 구현하는게 나을 때 또는 빈 가상함수를 만들어 몇몇 자식클래스만 구현부를 만들 때 사용한다.
참고
https://yozm.wishket.com/magazine/detail/2479/
https://unity.com/kr/blog/game-programming-patterns-update-ebook
'프로그래밍 일반' 카테고리의 다른 글
| 추상클래스 vs 인터페이스 (0) | 2026.04.09 |
|---|---|
| Service Locator vs Dependency Injection (0) | 2025.07.27 |