Unreal Engine/Functional Implementation

언리얼 엔진 GAS 텔레포트 구현 - UI 버튼으로 지정 위치 이동하기

보별 2025. 9. 16. 16:52

게임 시작 시 연출용으로 만들어둔 Teleport 기능은, 구조만 잘 잡아두면 다른 시스템에도 충분히 재사용할 수 있다.
이번에는 그 점을 활용해서, 기존에 GAS와 연동되어 있던 플레이어 등장용 Teleport 연출을 기반으로 Teleport Scroll 아이템 슬롯 버튼을 누르면 지정된 위치로 순간이동하는 기능을 구현했다.

이번 구현에서 반영하고자 한 목표는 아래와 같았다.

  • UI 버튼을 누르면 Teleport 발동
  • Level에 자유롭게 배치할 수 있는 특정 지점으로 이동
  • 기존에 만들어둔 GA_Teleport, GA_EndTeleport 재사용
  • 새로운 목적지를 쉽게 추가할 수 있도록 확장성 확보

단순히 버튼 하나와 좌표 하나를 직접 연결하는 방식으로도 구현은 가능하지만, 그렇게 만들면 목적지가 늘어날수록 관리가 빠르게 불편해진다.

그래서 이번에는 목적지를 유연하게 관리하기 위해, Gameplay Tag와 Subsystem을 이용한 중앙 관리 구조를 사용했다.

즉, 레벨에 배치된 각 텔레포트 목적지는 자신만의 태그를 가지고, 실제 이동 위치 조회는 TeleportManagerSubsystem이 담당하도록 설계했다.

 

1. 전체 구조

이번 텔레포트 기능의 흐름은 아래와 같다.

  1. 레벨에 텔레포트 목적지 액터를 배치한다.
  2. 각 목적지 액터는 자신의 Gameplay Tag를 기준으로 Subsystem에 자신을 등록한다.
  3. UI 버튼을 누르면 목적지 태그를 넘긴다.
  4. Subsystem이 해당 태그에 맞는 목적지 위치를 찾아준다.
  5. 그 위치를 기존 GA_Teleport에 전달한다.
  6. 기존 연출을 그대로 재사용해 Teleport가 실행된다.

즉, UI는 태그만 알고, 실제 위치 조회는 Subsystem이 담당하고, 이동 연출은 기존 Gameplay Ability가 처리하는 구조다.

이렇게 역할을 분리해두면 목적지가 추가되더라도 UI와 어빌리티 구조를 거의 건드리지 않아도 된다.

 

2. Teleport 목적지 액터 만들기

먼저 레벨에 배치할 텔레포트 목적지 액터인 TeleportDestinationPoint를 만들었다.
이 액터는 직접 플레이어를 이동시키는 역할을 하지는 않고, “이 태그의 목적지는 이 위치다”라는 정보를 관리 시스템에 등록하는 역할만 한다.

게임 시작 시에는 자신을 등록하고, 레벨에서 제거되거나 종료될 때는 등록을 해제하도록 만들었다.

TeleportDestinationPoint

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
TeleportDestinationPoint.h
 
#pragma once
 
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "GameplayTagContainer.h"
#include "TeleportDestinationPoint.generated.h"
 
UCLASS()
class PROJECTJIN_API ATeleportDestinationPoint : public AActor
{
    GENERATED_BODY()
    
public:    
    // Sets default values for this actor's properties
    ATeleportDestinationPoint();
 
protected:
    // Called when the game starts or when spawned
    virtual void BeginPlay() override;
    virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
 
public:    
    /** 텔레포트 목적지를 식별하는 고유 게임플레이 태그 */
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Teleport")
    FGameplayTag DestinationTag;
};
 
 
TeleportDestinationPoint.cpp
 
#include "TeleportDestinationPoint.h"
#include "TeleportManagerSubsystem.h"
#include "Engine/GameInstance.h"
 
ATeleportDestinationPoint::ATeleportDestinationPoint()
{
    PrimaryActorTick.bCanEverTick = false;
}
 
void ATeleportDestinationPoint::BeginPlay()
{
    Super::BeginPlay();
    if (DestinationTag.IsValid())
    {
        if (UGameInstance* GameInstance = GetGameInstance())
        {
            if (UTeleportManagerSubsystem* TeleportManager = GameInstance->GetSubsystem<UTeleportManagerSubsystem>())
            {
                TeleportManager->RegisterDestination(DestinationTag, this);
            }
        }
    }
}
 
void ATeleportDestinationPoint::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    if (DestinationTag.IsValid())
    {
        if (UGameInstance* GameInstance = GetGameInstance())
        {
            if (UTeleportManagerSubsystem* TeleportManager = GameInstance->GetSubsystem<UTeleportManagerSubsystem>())
            {
                TeleportManager->UnregisterDestination(DestinationTag);
            }
        }
    }
 
    Super::EndPlay(EndPlayReason);
}
cs

이 구조의 장점은 명확하다.
목적지를 추가하고 싶을 때 코드를 수정하는 대신, 레벨에 액터를 배치하고 태그만 지정하면 된다.

즉, 목적지 추가 비용이 매우 낮아진다.

 

3. 목적지를 중앙에서 관리하는 TeleportManagerSubsystem

다음으로 GameInstance에 붙어 있는 TeleportManagerSubsystem을 만들었다.
이 서브시스템은 레벨에 배치된 모든 TeleportDestinationPoint를 등록하고, 태그를 기준으로 위치를 찾아주는 역할을 맡는다.

이번 구조에서 핵심은 중앙 관리다.

  • 목적지 액터는 자기 자신을 등록만 한다.
  • UI는 태그만 보낸다.
  • 실제 위치 조회는 Subsystem이 처리한다.

즉, 각 시스템이 직접 서로를 참조하지 않도록 중간 관리자 계층을 둔 셈이다.

TeleportManagerSubsystem

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
TeleportManagerSubsystem.h
 
#pragma once
 
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "GameplayTagContainer.h"
#include "TeleportManagerSubsystem.generated.h"
 
/**
 * 레벨에 배치된 텔레포트 목적지 등록 및 관리용 서브시스템
 */
UCLASS()
class PROJECTJIN_API UTeleportManagerSubsystem : public UGameInstanceSubsystem
{
    GENERATED_BODY()
 
public:
    /** 텔레포트 목적지 등록 */
    void RegisterDestination(FGameplayTag DestinationTag, AActor* DestinationActor);
 
    /** 텔레포트 목적지 제거 */
    void UnregisterDestination(FGameplayTag DestinationTag);
 
    /** 태그에 해당하는 목적지의 월드 좌표를 가져옴 */
    bool GetDestinationLocation(FGameplayTag DestinationTag, FVector& OutLocation) const;
 
private:
    /** 등록된 텔레포트 목적지들을 저장하는 맵; 액터가 파괴될 경우를 대비해 TWeakObjectPtr 사용 */
    TMap<FGameplayTag, TWeakObjectPtr<AActor>> TeleportDestinations;
};
 
 
TeleportManagerSubsystem.cpp
 
#include "Character/TeleportManagerSubsystem.h"
 
void UTeleportManagerSubsystem::RegisterDestination(FGameplayTag DestinationTag, AActor* DestinationActor)
{
    if (!DestinationTag.IsValid() || !IsValid(DestinationActor))
    {
        return;
    }
 
    TeleportDestinations.Add(DestinationTag, DestinationActor);
}
 
void UTeleportManagerSubsystem::UnregisterDestination(FGameplayTag DestinationTag)
{
    if (!DestinationTag.IsValid())
    {
        return;
    }
 
    TeleportDestinations.Remove(DestinationTag);
}
 
bool UTeleportManagerSubsystem::GetDestinationLocation(FGameplayTag DestinationTag, FVector& OutLocation) const
{
    if (!DestinationTag.IsValid())
    {
        return false;
    }
 
    const TWeakObjectPtr<AActor>* DestinationActorPtr = TeleportDestinations.Find(DestinationTag);
    if (DestinationActorPtr && DestinationActorPtr->IsValid())
    {
        OutLocation = (*DestinationActorPtr)->GetActorLocation();
        return true;
    }
 
    // 해당 태그를 가진 목적지를 찾지 못했거나 액터가 유효하지 않은 경우
    return false;
}
cs

여기서 TWeakObjectPtr를 사용한 것도 의미가 있다.
목적지 액터가 파괴되거나 레벨에서 사라졌을 때, 강한 참조로 붙잡고 있지 않도록 하기 위함이다.

즉, 이 구조는 단순 조회뿐 아니라 레벨 액터의 생명주기까지 고려한 안전한 참조 방식이라고 볼 수 있다.

 

4. 레벨에 목적지 배치하고 태그만 지정하기

위에서 만든 TeleportDestinationPoint를 기반으로 BP를 하나 생성하면, Details 패널에서 Destination Tag를 직접 지정할 수 있게 된다.

예를 들어 Teleport.Castle 같은 태그를 추가하고, 해당 BP를 레벨의 원하는 위치에 배치한 뒤 그 태그를 지정하면 된다.

Gameplay Tag에 Teleport의 지점과 관련된 Tag 추가
BP를 Level의 원하는 지점에 배치한 뒤, 해당 BP의 Destination Tag에 위에서 생성해준 목적지 Tag를 지정

이 방식의 장점은 목적지 관리가 매우 직관적이라는 점이다.

  • 목적지를 새로 추가하고 싶으면 BP를 레벨에 하나 더 배치
  • 태그만 새로 지정
  • UI 버튼에서는 그 태그만 넘기면 됨

즉, 코드 수정 없이도 레벨 디자이너가 목적지를 확장하기 쉬운 구조가 된다.

 

5. UI 버튼에서 Teleport 이벤트 보내기

다음으로 UI에 있는 버튼과 텔레포트를 연결하기 위한 코드를 작성했다.
이번 구현에서는 ItemQuickBar에서 버튼 클릭 시 목적지 태그를 기반으로 Teleport Gameplay Event를 보내는 방식을 사용했다.

즉, 버튼은 직접 플레이어를 이동시키는 것이 아니라,

  • 목적지 태그를 넘기고
  • Subsystem에서 좌표를 찾고
  • 그 좌표를 Gameplay Event의 Payload에 담아
  • 기존 Teleport Ability를 발동시키는 구조다.

ItemQuickBar

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
ItemQuickBar.h
 
#pragma once
 
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "GameplayTagContainer.h"
#include "ItemQuickBar.generated.h"
 
/**
 * 
 */
UCLASS()
class PROJECTJIN_API UItemQuickBar : public UUserWidget
{
    GENERATED_BODY()
    
public:
    /** 지정된 위치로 텔레포트하는 게임플레이 이벤트 전송 */
    UFUNCTION(BlueprintCallable, Category = "Teleport Ability")
    void SendTeleportGameplayEvent(FGameplayTag DestinationTag);
};
 
 
ItemQuickBar.cpp
 
#include "ItemQuickBar.h"
#include "AbilitySystemComponent.h"
#include "Abilities/GameplayAbilityTypes.h"
#include "ProjectJin/Character/PlayerBase.h"
#include "ProjectJin/Core/PlayerStateBase.h"
#include "GameplayTagContainer.h"
#include "Engine/Engine.h"
#include "ProjectJIN/Character/TeleportManagerSubsystem.h"
#include "Engine/GameInstance.h"
 
void UItemQuickBar::SendTeleportGameplayEvent(FGameplayTag DestinationTag)
{
 
    if (!DestinationTag.IsValid())
    {
        return;
    }
 
    // TeleportManagerSubsystem을 통해 목적지 위치를 가져옴
    FVector Destination = FVector::ZeroVector;
    if (UGameInstance* GameInstance = GetGameInstance())
    {
        if (UTeleportManagerSubsystem* TeleportManager = GameInstance->GetSubsystem<UTeleportManagerSubsystem>())
        {
            if (!TeleportManager->GetDestinationLocation(DestinationTag, Destination))
            {
                return;
            }
        }
    }
 
    APlayerBase* OwningPlayer = Cast<APlayerBase>(GetOwningPlayerPawn());
    if (!OwningPlayer) return;
 
    // 플레이어 스테이트에서 ASC를 가져옴
    APlayerStateBase* PlayerState = OwningPlayer->GetPlayerState<APlayerStateBase>();
    if (!PlayerState) return;
 
    UAbilitySystemComponent* ASC = PlayerState->GetAbilitySystemComponent();
    if (!ASC) return;
 
    // 이벤트에 담아 보낼 데이터(Payload) 생성
    FGameplayEventData Payload;
    FGameplayAbilityTargetData_LocationInfo* LocationInfo = new FGameplayAbilityTargetData_LocationInfo();
    LocationInfo->TargetLocation.LocationType = EGameplayAbilityTargetingLocationType::LiteralTransform;
    LocationInfo->TargetLocation.LiteralTransform = FTransform(Destination);
 
    FGameplayAbilityTargetDataHandle TargetDataHandle;
    TargetDataHandle.Add(LocationInfo);
    Payload.TargetData = TargetDataHandle;
 
    // 사용할 이벤트 태그 정의
    FGameplayTag EventTag = FGameplayTag::RequestGameplayTag(FName("Ability.Common.TeleportStart"));
 
    // 로컬(클라이언트)에서 즉각적인 반응(예측)을 위해 이벤트 전송
    ASC->HandleGameplayEvent(EventTag, &Payload);
 
    // PlayerBase에 있는 RPC를 호출하여 서버에게도 동일한 이벤트를 실행
    OwningPlayer->Server_SendGameplayEventToActor(EventTag, Payload);
}
cs

코드 작성 후에 기존에 생성돼있던 WBP를 위에서 생성해준 ItemQuickBar를 부모 클래스로 하도록 수정해주면, Event Graph에서 아래와 같이 노드를 추가 및 연결이 가능하다.

Teleport Button의 OnClicked 이벤트에, 코드에서 생성해준 SendTeleportGameplayEvent 노드를 연결하고, Destination Tag를 위의 레벨에 배치한 TeleportDestination의 Tag로 설정해주면 된다.

 

이 구조를 사용하면 UI 버튼은 단지 DestinationTag만 알면 된다.
실제 이동 위치를 하드코딩하지 않아도 되기 때문에, 슬롯이 늘어나거나 목적지가 변경돼도 대응이 훨씬 수월하다.

 

6. 기존 GA_Teleport와 GA_EndTeleport 재사용하기

이번 구현의 핵심은 새로운 텔레포트 시스템을 처음부터 다시 만드는 것이 아니라, 기존에 플레이어 첫 등장 연출에 사용하던 Teleport Ability를 재사용한 것이다.

기존 구조에서는

  • Teleport 시작 시 GA_Teleport
  • Teleport 완료 시 GA_EndTeleport

가 사용되고 있었다.

그래서 플레이어가 게임 시작 시 이 두 Ability를 가지고 있도록 유지한 상태에서, GA_Teleport가 두 가지 상황을 모두 처리할 수 있도록 분기 로직을 추가했다.

  • TriggerEventData에 목적지 위치가 있으면 → UI 버튼을 통한 직접 텔레포트
  • 위치 정보가 없으면 → 기존 첫 등장 연출 로직

즉, 하나의 Ability가 게임 시작 시 연출용 TeleportUI 기반 목적지 이동 Teleport를 모두 처리하도록 확장한 셈이다.

GA_Teleport

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
GA_Teleport.h
 
protected:
    /** 텔레포트 시퀀스(GA_EndTeleport 활성화, 몽타주 재생 등)를 시작하는 헬퍼 함수 */
    void StartTeleportSequence(const FVector& Destination); // 추가
 
 
GA_Teleport.cpp
 
#include "ProjectJin/Character/PlayerBase.h" // 추가
#include "ProjectJin/AC/CameraManager.h" // 추가
 
void UGA_Teleport::ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData)
{
    Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);
 
    Player = Cast<APlayerBase>(ActorInfo->AvatarActor.Get());
 
    if (!Player)
    {
        EndAbility(Handle, ActorInfo, ActivationInfo, truetrue);
        return;
    }
 
    if (UCameraManager* CamManager = Player->CameraManager)
    {
        CamManager->Deactivate();
    }
 
    // TriggerEventData를 확인하여 UI 텔레포트인지, 첫 등장 연출인지 분기
    if (TriggerEventData && TriggerEventData->TargetData.IsValid(0))
    {
        // TargetData에서 위치 정보를 가져옴
        const FGameplayAbilityTargetData* TargetData = TriggerEventData->TargetData.Get(0);
        if (const FGameplayAbilityTargetData_LocationInfo* LocationData = static_cast<const FGameplayAbilityTargetData_LocationInfo*>(TargetData))
        {
            // UI를 통해 직접 텔레포트를 요청한 경우
            Player->GetCharacterMovement()->SetMovementMode(MOVE_None);
            const FVector Destination = LocationData->TargetLocation.LiteralTransform.GetLocation();
            StartTeleportSequence(Destination);
            return;
        }
    }
 
    // TriggerEventData에 위치 정보가 없는 경우 (기존의 첫 등장 연출 로직)
    Player->GetCharacterMovement()->SetMovementMode(MOVE_None);
 
    FTransform SpawnTransform;
    SpawnTransform.SetLocation(SpawnLocation);
 
    UTeleportTargetDataTask* WaitTargetData = UTeleportTargetDataTask::SetSpawnPosition(this);
 
    WaitTargetData->ValidData.AddDynamic(this, &UGA_Teleport::OnTargetDataReceived);
 
    WaitTargetData->ReadyForActivation();
}
 
void UGA_Teleport::OnTargetDataReceived(const FGameplayAbilityTargetDataHandle& Data)
{
    // 기존 로직을 StartTeleportSequence 헬퍼 함수로 옮기고, 해당 함수를 호출하도록 변경
    const FGameplayAbilityTargetData* TargetData = Data.Get(0);
    if (const FGameplayAbilityTargetData_LocationInfo* LocationData = static_cast<const FGameplayAbilityTargetData_LocationInfo*>(TargetData))
    {
        const FVector ReceivedLocation = LocationData->TargetLocation.LiteralTransform.GetLocation();
        StartTeleportSequence(ReceivedLocation);
    }
    else
    {
        EndAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo, truetrue);
    }
}
 
void UGA_Teleport::StartTeleportSequence(const FVector& Destination)
{
    UAbilitySystemComponent* ASC = GetAbilitySystemComponentFromActorInfo();
    if (!ASC)
    {
        EndAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo, truetrue);
        return;
    }
 
    FGameplayAbilitySpec* Spec = ASC->FindAbilitySpecFromClass(EndTeleportClass);
    if (!Spec)
    {
        EndAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo, truetrue);
        return;
    }
 
    // Spec이 유효하고, 아직 어빌리티가 활성화되지 않은 경우에만 실행
    if (Spec && Spec->IsActive() == false)
    {
        if (auto EndTeleport = Cast<UGA_EndTeleport>(Spec->GetPrimaryInstance()))
        {
            EndTeleport->SetSpawnLocation(Destination);
        }
 
        ASC->TryActivateAbility(Spec->Handle, false);
    }
 
    if (TargetMontage)
    {
        Player->PlayAnimMontage(TargetMontage);
 
        UAbilityTask_WaitDelay* DelayTask = UAbilityTask_WaitDelay::WaitDelay(this, 3.f);
 
        DelayTask->OnFinish.AddDynamic(this, &UGA_Teleport::OnTeleportCueComplete);
        DelayTask->ReadyForActivation();
    }
    else
    {
        OnTeleportCueComplete();
    }
}
cs

Player가 두 가지의 Ability를 가질 수 있도록 Player BP에서 할당

여기서 중요한 점은, 기존 텔레포트 연출의 핵심 흐름을 버리지 않고 목적지 입력 방식만 확장했다는 것이다.

즉, 연출 로직은 그대로 살리면서도 새로운 사용처를 붙일 수 있도록 구조를 넓힌 셈이다.

 

7. 실제 테스트 과정에서 발견한 문제

구현이 끝난 뒤 테스트를 해봤을 때, 처음에는 Teleport가 정상적으로 실행되지 않았다.
코드를 다시 확인해본 결과, 문제는 새로 추가한 UI 로직이 아니라 기존 BPGA_Teleport와 BPGA_EndTeleport의 Tag 설정 충돌에 있었다.

확인해보니 텔레포트가 실행되면 IsSkilling 태그가 붙게 되어 있었고, 동시에 BPGA_EndTeleport 쪽에서는 그 태그가 있으면 실행되지 않도록 막혀 있었다.

BPGA_Teleport Tag
BPGA_EndTeleport Tag

즉, 시작 Ability는 정상적으로 돌고 있었지만, 완료 Ability가 태그 조건 때문에 막히면서 전체 Teleport 흐름이 끊기고 있었던 것이다.

이후 해당 IsSkilling 태그 설정을 정리해주고 다시 테스트해보니, Teleport Scroll 버튼을 눌렀을 때 지정한 목적지로 정상적으로 순간이동하는 것을 확인할 수 있었다.

이 부분은 꽤 중요하다.
새 기능을 붙였는데 동작하지 않을 때, 항상 새 코드만 문제라고 생각하기 쉬운데
실제로는 기존 시스템 내부의 조건 충돌이 원인인 경우도 많기 때문이다.

 

이번 구현은 단순한 UI 버튼 이동 기능이 아니라, 기존에 만들어둔 Teleport 연출 시스템을 확장 가능한 구조로 재사용한 작업에 가깝다.

정리하면 이번 구조는 아래 흐름으로 동작한다.

  • 레벨에 텔레포트 목적지 액터를 배치하고 태그를 부여
  • 목적지 액터는 TeleportManagerSubsystem에 자신을 등록
  • UI 버튼 클릭 시 목적지 태그 전달
  • Subsystem이 태그에 맞는 실제 좌표를 조회
  • 조회된 좌표를 Gameplay Event에 담아 GA_Teleport 실행
  • 기존 GA_EndTeleport까지 이어져 기존 연출 그대로 사용

이 구조의 장점은 명확하다.

  • 목적지 추가가 쉬움
  • UI와 실제 좌표 관리가 분리됨
  • 기존 Teleport Ability를 재사용 가능
  • 향후 새로운 텔레포트 지역이 늘어나도 대응이 쉬움

이번 구현은 단순 이동 기능을 넘어서, Gameplay Tag 활용, Subsystem 기반 중앙 관리, UI와 GAS 연동, 기존 시스템 재사용, 디버깅을 통한 기존 태그 충돌 해결까지 함께 보여줄 수 있었다.