블루프린트 인터페이스 활용
인터페이스로 기능 계약을 정의하고 서로 다른 액터에 구현해 상속 없이 느슨하게 상호작용하는 구조를 만듭니다.
플레이어가 E 키를 눌렀을 때 앞에 있는 것이 문이면 열고, 레버면 당기고, 버튼이면 누르게 하고 싶다고 해 봅시다. 플레이어 쪽 그래프에서 "문인가? 레버인가? 버튼인가?"를 하나씩 확인하며 분기하면, 상호작용할 물건이 하나 늘 때마다 플레이어 블루프린트를 고쳐야 합니다. 그렇다고 문과 레버, 버튼을 블루프린트에서의 상속 기본 개념에서 본 상속으로 한 부모 아래에 묶기도 어색합니다. 셋은 같은 종류의 물건이 아니기 때문입니다.
블루프린트 인터페이스(Blueprint Interface)는 이 문제를 상속과 다른 방식으로 풉니다. 이 글에서는 인터페이스가 무엇을 정의하는지, 왜 상속만으로는 부족한지 살펴본 뒤, 인터페이스를 만들고 액터에 구현하고 다른 블루프린트에서 메시지로 호출하는 과정을 따라갑니다.
기능의 이름만 정하는 약속
휴대폰 충전기를 생각해 보면 이해가 쉽습니다. 충전기와 휴대폰이 같은 포트와 전력 규격을 지원하면 브랜드가 달라도 충전할 수 있습니다. 충전기는 "이 규격으로 전기를 보낸다"는 약속만 지킬 뿐, 휴대폰이 그 전기로 배터리를 어떻게 채우는지는 신경 쓰지 않습니다.
블루프린트 인터페이스도 비슷합니다. 인터페이스는 어떤 기능의 이름과 입력, 출력 매개변수만 정의하고, 그 기능을 실제로 어떻게 수행할지는 인터페이스를 구현(implement)하는 블루프린트 클래스에 맡깁니다. 공식 문서에 따르면 인터페이스에는 변수도, 편집할 수 있는 그래프도, 컴포넌트도 둘 수 없습니다. 인터페이스가 담는 것은 "이런 기능이 있다"는 약속뿐입니다. 충전기 비유와 다른 점도 있는데, 충전기는 연결만 하면 저절로 전기가 흐르지만 인터페이스는 호출하는 쪽이 대상을 직접 골라 메시지를 보내야 일이 일어납니다.
상속만으로 부족한 이유
상속은 "A는 B이다(A is a B)"라는 관계를 만듭니다. BP_SportsCar는 BP_Car이고, 그래서 자동차의 공통 기능을 물려받습니다. 그런데 블루프린트는 단일 상속(single inheritance)만 지원하므로, 블루프린트 하나는 부모를 하나만 가질 수 있습니다.
게임에서는 서로 다른 종류의 블루프린트가 같은 기능을 가져야 하는 경우가 많습니다. BP_Door(문), BP_Lever(레버), BP_Button(버튼)이 모두 상호작용할 수 있어야 한다면, 이들을 한 부모 클래스로 억지로 묶는 설계는 부자연스럽습니다. 문은 레버가 아니고 레버는 버튼이 아니며, 문은 이미 다른 부모 클래스를 가지고 있을 수도 있습니다.
인터페이스는 "A는 B를 할 수 있다(A can do B)"라는 능력 관계를 정의합니다. 세 액터가 모두 BPI_Interactable이라는 상호작용 인터페이스를 구현하면, 각자 그 인터페이스가 정한 Interact 기능을 자신만의 방식으로 수행합니다. 문은 열리고, 레버는 당겨지고, 버튼은 눌리는 식입니다. 호출하는 쪽은 유효한 대상이 이 인터페이스를 구현했는지만 보고 같은 방식으로 호출하며, 실제로 문이 열리는 효과는 대상의 구현이 맡습니다. 그래서 호출 방식과 대상을 고르는 방식이 그대로라면, 상자 같은 새 클래스가 인터페이스를 구현하는 것만으로 기존 호출 로직을 그대로 쓸 수 있습니다.
인터페이스 만들고 함수 정의하기
콘텐츠 브라우저(Content Browser)에서 마우스 오른쪽 버튼을 누르거나 Add 버튼을 누르고, Blueprint 메뉴의 Blueprint Interface를 고릅니다. 이름은 보통 BPI_ 접두사를 붙여 BPI_Interactable처럼 짓습니다. 만든 인터페이스 에셋을 더블클릭하면 인터페이스 에디터가 열립니다.
인터페이스 에디터의 마이 블루프린트(My Blueprint) 패널에서 함수 목록의 + 버튼을 눌러 새 함수를 추가하고, 이름을 Interact로 바꿉니다. 이 함수에 필요한 입력과 출력 매개변수는 디테일(Details) 패널에서 추가합니다. 이 글의 예에서는 상호작용을 시도한 액터를 넘기기 위해 Actor 오브젝트 참조 타입의 입력 InteractingActor를 하나 두고, 출력은 두지 않습니다. 인터페이스 함수에는 로직을 넣을 그래프가 없으므로 이름과 매개변수만 정의하면 됩니다. 정의를 마치면 컴파일하고 저장합니다.
액터에 인터페이스 구현하기
인터페이스 기능을 넣을 액터 블루프린트, 예를 들어 BP_Door를 엽니다. 툴바의 Class Settings 버튼을 누르면 디테일 패널에 클래스 설정이 나타나는데, Interfaces 영역의 Implemented Interfaces에서 Add 드롭다운을 열어 BPI_Interactable을 검색해 고릅니다. 컴파일하면 마이 블루프린트 패널에 Interfaces 항목이 새로 생기고, 그 아래에 Interact가 나타납니다.
이제 Interact의 실제 동작을 만듭니다. 이 예제의 Interact는 출력이 없으므로, Interfaces 항목의 Interact를 오른쪽 클릭해 Implement Event를 고르면 이벤트 그래프에 Event Interact 노드가 생깁니다. 출력이 있는 인터페이스 함수라면 이벤트가 아니라 반환값을 연결하는 함수 그래프에서 구현합니다. Event Interact 뒤에 Print String을 이어 "문이 상호작용되었습니다!"를 출력하게 하고, 필요하다면 문이 열리는 애니메이션을 재생하는 로직을 덧붙인 뒤 컴파일하고 저장합니다. 레버와 버튼도 같은 인터페이스를 추가하고 각자의 Event Interact에 자신에게 맞는 동작을 넣습니다.
다른 블루프린트에서 메시지 보내기
이제 플레이어 캐릭터 블루프린트 같은 다른 블루프린트에서 Interact를 호출합니다. 상호작용 키를 눌렀을 때 실행할 이벤트가 출발점입니다. 이 예는 해당 블루프린트가 입력을 받도록 구성되어 있다고 가정하며, 언리얼 엔진 5의 기본 입력 방식인 향상된 입력(Enhanced Input)을 쓰는 프로젝트라면 상호작용용 Input Action의 이벤트를 연결합니다.
다음으로 상호작용할 대상을 고릅니다. Line Trace By Channel의 결과에서 맞은 액터를 가져오거나, Get Overlapping Actors로 얻은 배열에서 기준에 맞는 액터 하나를 고르는 방법이 있습니다. 어느 쪽이든 대상이 유효할 때만 다음으로 이어서 호출합니다. 가져온 액터 참조 핀에서 드래그한 뒤 Interact를 검색하면 여러 노드가 보이는데, 이때 Interact (Message)처럼 Message가 붙은 노드를 고릅니다. Target 핀에는 고른 액터 하나를, InteractingActor에는 상호작용을 시도한 액터 자신을 연결합니다.
Interact (Message) 노드는 대상이 인터페이스를 구현했으면 그 대상의 Event Interact를 실행합니다. 공식 문서에 따르면 메시지 호출은 어떤 오브젝트에나 보낼 수 있고, 대상이 인터페이스를 구현하지 않았다면 오류 없이 조용히 실패합니다. 그래서 "상호작용할 수 없는 대상입니다" 같은 피드백이 필요하다면 Does Implement Interface 노드의 결과로 따로 분기합니다. 또 메시지 한 번은 지정한 대상 하나에만 가며, 같은 인터페이스를 구현한 모든 액터에 방송되지는 않습니다.
이 방식의 장점은 Interact (Message)를 연결할 때 대상이 BP_Door인지, BP_Lever인지, BP_Button인지 알 필요가 없다는 것입니다. 플레이어 쪽에서 중요한 것은 대상이 BPI_Interactable을 구현했는지뿐입니다. 그래프를 읽을 때도 대상 확인, 메시지 호출, 구현별 처리를 나누어 보면 어디서 문제가 생겼는지 찾기 쉽습니다.
입력 없이 메시지부터 확인하기
입력이나 트레이스를 아직 준비하지 않았다면 메시지 호출만 먼저 확인할 수 있습니다. Interact를 구현해 문구만 출력하는 BP_Door 인스턴스 하나와, 호출을 맡을 BP_WelcomeMessage 인스턴스 하나를 레벨에 배치합니다. 호출용 액터에 Actor 오브젝트 참조 변수 TargetDoor를 만들고 Instance Editable을 켠 뒤 컴파일하고, 레벨에 놓인 그 인스턴스의 디테일 패널에서 TargetDoor에 배치한 문을 지정합니다.
호출용 액터의 그래프에서는 BeginPlay에서 TargetDoor의 유효성을 확인한 뒤 Interact (Message)로 실행 선을 잇고, Target에는 TargetDoor를, InteractingActor에는 Self를 넣습니다. 컴파일하고 에셋과 레벨을 저장한 뒤 플레이하면 문 쪽의 출력이 한 번 나타나야 합니다. 이 경로는 메시지 호출만 떼어 확인하는 것이며, 원래 목표인 키 입력과 대상 선택은 그다음에 따로 확인합니다.
인터페이스를 쓰면 좋아지는 점
인터페이스를 쓰면 하나의 메시지 호출로 여러 종류의 블루프린트가 각자의 방식으로 기능을 수행하게 할 수 있습니다. 이렇게 같은 호출이 대상에 따라 다르게 동작하는 성질을 다형성(polymorphism)이라고 합니다.
또 블루프린트 사이의 의존이 줄어드는 느슨한 결합(loose coupling)을 얻습니다. 호출하는 쪽은 특정 클래스를 몰라도 인터페이스만 알면 되므로, 상호작용할 물건이 늘어나도 호출 쪽을 고칠 일이 줄어듭니다. 새 클래스는 기존 인터페이스를 구현하기만 하면 같은 호출 방식을 씁니다. 다만 인터페이스 함수의 이름이나 입력, 출력을 바꾸면 모든 구현과 호출부를 함께 고쳐야 하므로, 인터페이스는 처음에 신중히 정하는 편이 좋습니다.
협업에도 도움이 됩니다. 팀원끼리 인터페이스로 "이 기능은 이런 이름과 매개변수로 부른다"를 먼저 정해 두면, 호출하는 쪽과 구현하는 쪽을 각자 따로 만든 뒤 나중에 합칠 수 있습니다. 블루프린트 사이의 통신 방법으로서의 인터페이스는 블루프린트 인터페이스를 통한 통신에서 다른 통신 방법과 비교하며 다시 다룹니다.
정리
- 블루프린트 인터페이스는 기능의 이름과 입력, 출력만 정의하며, 변수와 그래프, 컴포넌트를 가질 수 없습니다. 실제 동작은 구현하는 클래스가 정합니다.
- 상속은 "A는 B이다", 인터페이스는 "A는 B를 할 수 있다"는 관계이며, 단일 상속으로 묶기 어려운 클래스들이 같은 기능을 갖게 할 때 인터페이스를 씁니다.
- 인터페이스는
Class Settings의Implemented Interfaces에서 추가하고, 출력이 없는 함수는Implement Event로 이벤트를 만들어 구현합니다. - 호출하는 쪽은
Interact (Message)같은 메시지 노드를 쓰며, 대상이 인터페이스를 구현하지 않으면 메시지는 조용히 실패합니다. - 메시지 한 번은
Target에 연결한 대상 하나에만 갑니다.
연습 문제
1. 구현한 액터가 여러 개일 때
문 A와 레버 B가 모두 BPI_Interactable을 구현합니다. 한 번의 Interact (Message)에 Target으로 A만 넘기고, A가 B를 다시 호출하지도 않습니다. B의 구현도 자동으로 실행될까요?
해설 보기
A의 구현만 실행됩니다. 인터페이스는 호출할 기능의 약속을 공유할 뿐, 그 인터페이스를 구현한 모든 액터에 방송하는 연결이 아닙니다. 메시지는 Target에 연결한 대상 하나에만 갑니다.
B도 처리하려면 B를 대상으로 따로 호출하거나, 여러 대상에 알리는 구조를 명시적으로 만들어야 합니다. 또 대상 참조가 None이면 호출을 이어 가지 않도록 유효성을 확인하고, 유효한 대상이라도 인터페이스가 없는 경우의 피드백은 따로 설계합니다.
2. 인터페이스가 없는 대상
플레이어가 인터페이스를 구현하지 않은 바위를 바라보고 상호작용 키를 눌렀습니다. 바위 참조는 유효하고, 그 참조로 Interact (Message)를 호출했습니다. 오류가 날까요? 플레이어에게 "상호작용할 수 없습니다"를 보여 주려면 어떻게 해야 할까요?
해설 보기
오류는 나지 않습니다. 메시지 호출은 대상이 인터페이스를 구현하지 않았으면 조용히 실패하므로, 바위 쪽에서는 아무 일도 일어나지 않고 그래프도 멈추지 않습니다.
이 조용한 실패 때문에 피드백이 필요할 때는 따로 분기해야 합니다. 메시지를 보내기 전에 Does Implement Interface에 대상과 BPI_Interactable을 넣어 결과가 거짓이면 안내 문구를 보여 주고, 참일 때만 메시지를 보냅니다.
3. 상자를 새로 추가할 때
문, 레버, 버튼에 이어 열 수 있는 상자 BP_Chest를 추가하려고 합니다. 플레이어 블루프린트의 호출 로직을 고쳐야 할까요? 반대로 Interact에 상호작용 종류를 나타내는 입력을 하나 더 추가한다면 어떨까요?
해설 보기
대상을 고르는 방식이 상자도 찾을 수 있다면, 플레이어의 호출 로직은 고칠 필요가 없습니다. BP_Chest에 BPI_Interactable을 추가하고 Event Interact에 상자가 열리는 동작을 넣으면, 기존의 Interact (Message)가 그대로 상자에도 동작합니다.
반면 Interact의 입력을 바꾸면 인터페이스의 약속 자체가 바뀝니다. 그러면 이 인터페이스를 구현한 모든 클래스와, 메시지를 보내는 모든 호출부를 새 매개변수에 맞게 함께 고쳐야 합니다.
인터페이스를 클래스에 추가하고 호출하는 방법은 언리얼 엔진 공식 문서의 블루프린트 인터페이스 구현에서 확인할 수 있습니다.
다음 글 액터 초기화와 종료 이벤트에서는 액터가 만들어지고 사라지는 동안 어떤 이벤트가 어떤 순서로 실행되는지 살펴봅니다.