SOLID 원칙: 객체 지향 설계의 기초
소프트웨어 개발에서 효율적이고 유지보수가 용이한 코드를 작성하기 위해서는 객체 지향 설계를 잘 이해하고 적용하는 것이 매우 중요합니다. 그 중에서 SOLID 원칙은 객체 지향 설계의 핵심을 이루는 다섯 가지 중요한 원칙으로, 이를 통해 개발자는 더 좋은 코드를 작성할 수 있습니다. 이 원칙들은 각기 다른 측면에서 코드의 확장성, 유연성, 유지보수성을 향상시킵니다.
1. 단일 책임 원칙 (Single Responsibility Principle, SRP)
정의
단일 책임 원칙(SRP)은 하나의 클래스가 하나의 책임만 가져야 한다는 원칙입니다. 즉, 클래스가 수행하는 기능은 하나의 작업으로 집중되어야 하며, 해당 작업과 관련된 변경만 있을 때 클래스가 수정되어야 합니다. 이를 통해 클래스를 수정할 때 다른 부분에 영향을 미치는 일을 최소화할 수 있습니다.
예시: 캐릭터 이동과 애니메이션 관리 분리
게임 개발에서 흔히 캐릭터의 이동과 애니메이션을 처리해야 하는 경우가 있습니다. 초기에 모든 기능을 하나의 Character 클래스에 넣을 수도 있지만, 이는 SRP를 위반할 가능성이 큽니다. 이동 로직과 애니메이션 관리가 각각 독립적인 책임으로 분리되어야 합니다.
잘못된 예시:
public class Character
{
public void Move(float x, float y)
{
// 이동 로직
Debug.Log($"Moving to {x}, {y}");
}
public void PlayAnimation(string animationName)
{
// 애니메이션 로직
Debug.Log($"Playing animation: {animationName}");
}
}
위 코드에서는 이동과 애니메이션 관리가 Character 클래스에 모두 포함되어 있습니다. 이러한 설계는 클래스가 너무 많은 책임을 가지게 되어 변경 시 복잡성이 증가할 수 있습니다.
SRP를 준수한 예시: 이동과 애니메이션 관리를 각각 분리하여 각 클래스가 하나의 책임만 가지도록 설계합니다.
캐릭터 이동 클래스 (CharacterMovement)
public class CharacterMovement
{
public void Move(float x, float y)
{
Debug.Log($"Moving to {x}, {y}");
}
}
캐릭터 애니메이션 클래스 (CharacterAnimation)
public class CharacterAnimation
{
public void PlayAnimation(string animationName)
{
Debug.Log($"Playing animation: {animationName}");
}
}
Character 클래스 Character 클래스는 이동과 애니메이션 클래스를 조합하여 사용하는 고수준 클래스가 됩니다.
public class Character
{
private CharacterMovement _movement = new CharacterMovement();
private CharacterAnimation _animation = new CharacterAnimation();
public void MoveCharacter(float x, float y)
{
_movement.Move(x, y);
_animation.PlayAnimation("Run");
}
}
SRP 준수의 이점
- 유지보수성 향상: 이동 로직이나 애니메이션 로직 중 하나를 수정하더라도 다른 기능에 영향을 미치지 않음.
- 확장성 증가: 이동 로직에 새로운 기능(예: 대쉬나 점프)을 추가하더라도 애니메이션 클래스는 수정할 필요가 없음.
2. 개방폐쇄 원칙 (Open/Closed Principle, OCP)
정의
개방폐쇄 원칙은 코드가 확장에는 열려 있고, 수정에는 닫혀 있어야 한다는 원칙입니다. 즉, 기존 코드를 변경하지 않고도 새로운 기능을 추가할 수 있도록 해야 합니다. 이는 상속, 인터페이스, 추상 클래스 등을 통해 구현할 수 있습니다.
예시: 유니티에서 오디오 시스템 확장
AudioManager 클래스를 예로 들어 보겠습니다. 기존의 PlaySound 메서드가 특정 형식의 사운드만 재생한다고 가정할 때, OCP를 준수하기 위해 새로운 사운드 유형을 추가할 때 기존 코드를 수정하지 않고 기능을 확장해야 합니다.
public interface IAudioPlayer
{
void Play(string sound);
}
public class SimpleAudioPlayer : IAudioPlayer
{
public void Play(string sound)
{
Debug.Log("Playing sound: " + sound);
}
}
public class AdvancedAudioPlayer : IAudioPlayer
{
public void Play(string sound)
{
Debug.Log("Advanced Playing sound: " + sound);
}
}
AudioManager는 다양한 AudioPlayer 구현을 통해 사운드 재생 방식을 확장할 수 있으며, 기존 코드를 변경하지 않고 새로운 기능을 추가할 수 있습니다.
잘못된 예시: 확장 시 기존 코드 수정
상황: AudioManager가 오직 하나의 방식으로만 소리를 재생한다고 가정해보면 새로운 소리 재생 방식을 추가해야 할 때, 기존 코드를 수정하는 일이 발생합니다.
public class AudioManager
{
public void PlaySound(string soundType, string sound)
{
if (soundType == "Simple")
{
Debug.Log("Playing simple sound: " + sound);
}
else if (soundType == "Advanced")
{
Debug.Log("Advanced playing sound: " + sound);
}
else
{
Debug.LogError("Unknown sound type!");
}
}
}
문제점
- 기존 코드 수정
새로운 소리 재생 방식을 추가하려면 PlaySound 메서드에 새로운 else if를 추가해야 합니다. - 유지보수 어려움
코드가 복잡해지면 수정할 때 실수가 발생할 가능성이 높아집니다. 예를 들어 기존의 else if 조건문을 잘못 건드리면 다른 기능이 망가질 수 있습니다. - 확장성이 부족
새로운 방식이 추가될 때마다 PlaySound 내부의 로직이 계속 커지며 수정해야 할 코드가 늘어납니다.
3. 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)
정의
리스코프 치환 원칙은 자식 클래스는 부모 클래스를 대체할 수 있어야 한다는 원칙입니다. 즉, 자식 클래스의 공통된 기능들만 부모 클래스에 작성해야 합니다. 자식 클래스가 부모 클래스를 참조 받은 후 기능을 없앤 코드로 바꾼다면 이 원칙에 위반되는 것입니다.
예시: 유니티에서의 물체 관리
Character라는 기본 클래스를 상속받은 PlayerCharacter와 EnemyCharacter 클래스가 있을 때, 이들이 Character를 대체할 수 있어야 합니다. Character 클래스의 기본 메서드를 모든 자식 클래스에서 동일하게 작동하게 해야 합니다.
public class Character
{
public virtual void Move()
{
Debug.Log("Character is moving");
}
}
public class PlayerCharacter : Character
{
public override void Move()
{
Debug.Log("Player is moving");
}
}
public class EnemyCharacter : Character
{
public override void Move()
{
Debug.Log("Enemy is moving");
}
}
이 코드에서 PlayerCharacter와 EnemyCharacter는 Character의 메서드를 적절히 오버라이드하여 리스코프 치환 원칙을 준수합니다. 만약 Character 메서드 중 Move외에 Fly가 있다고 가정하고 EnemyCharacter에는 Fly가 필요없는데도 상속을 받는다면 이는 리스코프 치환 원칙에 어긋나는 행위입니다.
4. 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)
정의
인터페이스 분리 원칙은 클라이언트가 자신이 사용하지 않는 메서드에 의존하지 않도록 해야 한다는 원칙입니다. 즉, 여러 기능이 섞여 있는 큰 인터페이스 대신, 기능별로 작은 인터페이스를 만들어 필요한 기능만 구현하도록 하는 것입니다. 이를 통해 불필요한 의존성을 줄이고, 코드의 유연성과 유지보수성을 높일 수 있습니다.
예시: 유니티에서의 UI 관리
유니티에서 UI 관리 클래스를 설계할 때, IUManager라는 큰 인터페이스 하나를 만들기보다는, 각 UI 화면별로 독립적인 인터페이스를 만들면 ISP를 준수할 수 있습니다.
public interface IMenuUI
{
void ShowMenu();
}
public interface IGameUI
{
void ShowGameUI();
}
public class MainMenuUI : IMenuUI
{
public void ShowMenu() { Debug.Log("Displaying Main Menu"); }
}
public class GameHUDUI : IGameUI
{
public void ShowGameUI() { Debug.Log("Displaying Game HUD"); }
}
이렇게 인터페이스를 분리하면 각 UI 클래스가 필요한 메서드만 구현하게 되어, 코드가 더 깔끔하고 유지보수하기 쉬워집니다.
잘못된 예시: 인터페이스 분리 원칙을 어기는 큰 인터페이스
만약 모든 UI 기능을 하나의 큰 인터페이스에 몰아넣었다면, 필요하지 않은 메서드까지 각 클래스에서 구현해야 합니다. 아래는 이를 보여주는 예시입니다.
// 잘못된 예시: 모든 UI 기능을 포함하는 큰 인터페이스
public interface IUIManager
{
void ShowMenu();
void ShowGameUI();
void ShowInventory();
void ShowSettings();
}
// 메인 메뉴 UI 클래스
public class MainMenuUI : IUIManager
{
public void ShowMenu()
{
Debug.Log("Displaying Main Menu");
}
public void ShowGameUI()
{
// MainMenuUI에는 이 기능이 필요 없음
}
public void ShowInventory()
{
// MainMenuUI에는 이 기능이 필요 없음
}
public void ShowSettings()
{
Debug.Log("Displaying Settings from Main Menu");
}
}
문제점
- 불필요한 메서드 구현
MainMenuUI 클래스는 ShowGameUI나 ShowInventory 같은 메서드를 전혀 필요로 하지 않지만, 큰 인터페이스 IUIManager를 구현했기 때문에 불필요한 메서드를 강제로 구현해야 합니다. - 유지보수의 어려움
만약 IUIManager에 새로운 메서드를 추가하면, 이 인터페이스를 구현하는 모든 클래스에 변경 사항이 생깁니다. 이렇게 되면 작은 기능 변경에도 많은 코드 수정이 필요해집니다. - 코드의 의존성 증가
클래스가 자신과 관련 없는 메서드에 의존하게 되어 코드가 복잡해지고 결합도가 증가합니다.
5. 의존성 역전 원칙 (Dependency Inversion Principle, DIP)
정의
의존성 역전 원칙은 상위 모듈이 하위 모듈의 것을 직접 가져와선 안 되고, 둘 다 추상화에 의존해야 한다는 원칙입니다. 또한, 추상화된 인터페이스나 클래스를 통해 의존성을 받아야 합니다.
예시: 유니티에서의 네트워크 관리
NetworkManager는 게임의 네트워크 처리를 담당하는 클래스입니다. NetworkManager가 INetworkConnection 인터페이스를 통해 네트워크 연결을 처리하도록 하면, DIP를 준수할 수 있습니다.
public interface INetworkConnection
{
void Connect();
}
public class WifiConnection : INetworkConnection
{
public void Connect() { Debug.Log("Connecting via Wifi"); }
}
public class MobileDataConnection : INetworkConnection
{
public void Connect() { Debug.Log("Connecting via Mobile Data"); }
}
public class NetworkManager
{
private readonly INetworkConnection _connection;
public NetworkManager(INetworkConnection connection)
{
_connection = connection;
}
public void ConnectToNetwork() => _connection.Connect();
}
이렇게 의존성을 주입받으면 네트워크 연결 방식에 대한 구현을 변경해도 NetworkManager는 수정하지 않고 다른 연결 방식을 쉽게 추가할 수 있습니다.
마무리
여러 디자인 패턴들이 SOLID를 따라 작성하는 코드이므로 SOLID를 따라 코드를 작성하는 습관을 가지면 앞으로의 코딩습관의 확장성과 유지보수성이 크게 향상됩니다. SOLID 원칙은 게임 개발뿐만 아니라 다양한 소프트웨어 프로젝트에서도 매우 유용하게 활용될 수 있는 중요한 설계 가이드라인입니다.
https://www.youtube.com/watch?v=J6F8plGUqv8