[Unity] AI 시대의 클린 아키텍처

KYO
KYO
∙∙조회 25
[Unity] AI 시대의 클린 아키텍처

들어가며

로버트 C 마틴의 <클린 아키텍처>는 엉클밥의 블로그 포스트에서 시작되었다. 오늘날까지도 많은 개발자에게 사랑받는 소프트웨어 설계 아이디어이며, 동시에 그만큼 많이 비판받는 것이기도 하다.

참고로 나는 전자다(아직 잘 안 써버릇해서 그런 듯함)

이 글은 UnityKorea 유튜브 채널에 올라온 유나이트 서울 2026 발표 중 하나를 본 것이 작성의 계기가 되었다.

핵심 아이디어

소스코드의 의존성은 항상 안쪽을 향해야한다. 정책(골드가 부족하면 구매 실패)은 세부사항(PlayerPrefs에 저장)에 끌려다니면 안 된다.

여기서 '안쪽'이 무엇인지에 대해서는 아래 그림을 참고하면 좋다.

계층

클린 아키텍처는 핵심 아이디어를 구현하기 위한 계층 구조를 갖는다. 가장 안쪽의 Enterprise Business Rules(비즈니스 로직)은 그 자체로 완결되고, 바깥쪽으로 갈수록 안쪽에 의존한다.

Enterprise Business Rules(도메인)

도메인 층은 비즈니스 규칙이 자리한다. 여기있는 것들은 어느 게임의 규칙에 해당한다. 서드파티에 의존하지 않고 외부 툴에 의존하지 않는 순수 C#(POCO) 코드로 이루어진다.

보통 도메인이라고 부르는데, 아래와 같은 것들이 이 계층에 들어간다.

  • 엔티티: ID를 갖고 상태가 변하는 것. 상태가 변해도 정체성은 유지된다.
  • ValueObject: 값으로써 동등한 것. 불변(immutable)이다.
  • 도메인 서비스: 여러 엔티티에 걸친 비즈니스 로직

Application Business Rules(애플리케이션)

애플리케이션 계층은 클린아키텍처에서 가장 중요하고 어려운 부분이라고 생각한다. 개념적으로는 소프트웨어 내에서 하나의 시나리오 규칙을 정의한다.

  • UseCase: DoSomething 과 같이 동사와 목적어로 명명되어서, 단일 동작에 대해 구현한다. 입력 Port를 구현한다.
  • Port: UseCase에 접근하기 위한 입력 및 출력 포트. 인터페이스이다. 출력 포트는 다음 계층인 어댑터 계층에 구현체가 있다.

기억해둘 것은, 여기에 외부 계층이 구현할 포트(인터페이스)가 있다는 점이다. 여기가 DIP - '인터페이스가 안쪽, 구현체가 바깥쪽'이 적용된 부분이며 로직과 View 층의 분리가 가능해지는 핵심이다.

Interface Adapters(Adapter)

어댑터 계층은 개념적으로는 안쪽과 소통하기 위한 어댑터 역할을 하는데, 구체적으로는 아래와 같은 것들이 있다.

  • Controller: UseCase로의 입력을 위한 포트를 구현한것
  • Presenter: UseCase의 출력을 위한 포트를 구현한것
  • Gateway: 기타 등등. 왜 기타등등이냐면, 얘네들은 안쪽과의 소통 계약을 위한 역할이지 뭐라 통일해서 부를게 없다. 그래도 굳이 나열하자면...
    • 엔티티 접근(Save(Entity), Load():Entity)
    • 마스터 데이터 조회(TryGet)
    • 서버호출(Do(cmd): response)
    • 시간(IClock.Now), 난수(IRandom.Next), 로거(ILogger.LogSpend) 등이 있다.

중요한건 이들이 전부 어댑터라는 사실이다. 이들은 모두 애플리케이션층이 선언한 포트 인터페이스를 구현한다. 이것이 매우 중요하다.

Framework & Drivers(Infrastructure, Presentation)

Unity 게임 개발자라면, 여기부터 MonoBehaviour라는 단어가 나오기 시작한다. 이전 계층까지는 using UnityEngine; 을 쓸 수 없게 asmdef로 강제(noEngineReferences: true)하기 때문이다. 이 계층에 와서야 외부 API와의 소통이 이루어진다.

이 계층의 특징은 가장 코드가 적다는 점일 것이다. 예시는 아래와 같다.

  • (Unity기준) TextMeshPro, PlayerPrefs 등의 엔진 api와 View Monobehaviour.
  • 서드파티 SDK
  • ...그냥 언젠가 타인이 바꿔버릴 수 있는 것들이라고 생각하면 된다.

사실 Framework랑 Adapter의 경계는 다른 계층에 비해 좀 흐릿하다. 둘 다 using UnityEngine이 가능하고 엔진 등 서드파티에 종속될 수 있지만, 그럼에도 분류하는 것은 단순한 유지보수에서의 편의성 때문이다.

예를 들어 엔진이 깨졌다면(Unity6에서 Unity7로의 버전업 등) 어댑터 구현체 API 몇 개를 고치면 되고(UniTask -> Awaitable 등), Framework의 Monobehaviour나 프리팹 등의 에셋은 박살이 날 것이다.

실제로 이 경계를 사이에 둔 어댑터와 프레임워크는 asmdef를 같이 쓰기도 한다.

장점과 단점

단점부터 짚자.

  1. 귀찮다. 진짜 여간 귀찮은 게 아니다. 인터페이스가 쓸데없이 많아지고(쓸데없진 않다), 도메인도 굉장히 세분화된다고 한다.
  2. 어렵다. 진짜 여간 어려운 게 아니다.

그래서 인게임 전투처럼 매 프레임 계산이 빡빡하게 이루어지는 곳에는 부적합하고, 메타게임에 쓰는 편이 좋다.

이제 장점 짚자.

  1. 데이터와 로직과 SDK의 명확한(asmdef) 분리로 유지보수가 매우 편리하다.
  2. (중요) AI가 이 구조에서 매우 안정적으로 동작할 수 있다. 코드를 뱉어내는게 아니라 적절한 위치에 적절한 테스트코드와 함께 안전한 기능 구현이 가능하다. 코드 리뷰어는 테스트 코드와 그 결과만 읽으면 된다!
  3. "이런거 저런거 구현해줘. UseCase에 로직 넣고 테스트도 같이 써" 같은 명령이 된다.
  4. 에디터 없이 로직 검증이 가능하다.

사실 이 글을 쓰게 된 계기도 AI때문이다. 아무리 구조를 잘 짜놓으려 해도 AI가 뱉어내는 코드를 읽는 데는 한 세월이다. 팀프로젝트에선 읽어야할 코드가 팀 인원만큼의 에이전트로부터 나온다. 그걸 읽다 보면 정신이 나간다.

중요한건 코드가 제대로 작동하는지에 대한 것밖에 없다. 그래서 단위 테스트를 도입했으나 잘 지켜지지 않는다. 고민을 해보니까, 애초에 프로젝트 내에 하네스가 느슨했던 것 뿐이었다. 모듈을 제대로 분리해서 계층별로 작업 디렉토리를 관리했으면 조금 나았을까? 그리고 로직을 유즈케이스나 도메인에 몰아넣고 테스트를 일일이 작성하게 에이전트에 강제했더라면 어땠을까?

클린 아키텍처의 장점은 이 고민을 어느 정도 해소해준다는 점이다. 이 설계는 내가 읽어야할 코드를 테스트 코드와 테스트 결과만으로 줄어든다!

사실 계층은 예시다

참고로 계층 분리는 밥 아저씨가 제안한 것이고, 실제로 중요한 것은 의존성이 안쪽이라는 사실 뿐이다. 이것만 지키면 계층이 몇 개든 상관이 없다.

뭔가 느낀점?

그래도 항상 하는 생각이 완벽한 건 없다는 생각이다. 언제나 이런 생각이 들 때 프로젝트를 엎는 것은 최악의 선택이었다. 앞으로는 이 의존성의 방향을 잘 고민하면서 코드를 짜야겠다 싶었다.

좌우지간.

나는 불어나는 규모에 대응할 수 있는 안정적인 아키텍처나 패턴에 관심이 조금 있는 편인데, 클린아키텍처는 옛날에 로버트C마틴씨의 책을 갖고 있었으나 어려워서 이해가 안 돼 읽다 말았다. 유니티 유나이트 서울 2026에서 이걸 다뤄줘서 도움이 많이 됐고, 최근에 알게 된 Unity Test Framework, *.asmdef 의 올바른 사용법에 대해 알게 된 듯하다.

그리고 먼저 공부해야 했던 것은 SOLID가 아니라 클린 아키텍처의 핵심 개념이 아니었을까 싶기도 하고. DIP같은 건 알아봤자 왜 쓰는지 몰라서는 의미가 없다. 모듈이 뭔지도 모르는데 DIP를 공부해서 무슨 의미가 있을까. 그리고 지금까진 그랬던 것 같다. 역시 나무가 아닌 숲을 봐야 된다는 느낌;

 

끗.

참고자료

이미지: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html