개발/유니티

[Unity] 변수 값은 바뀌는데 왜 UI 텍스트는 그대로일까?

kimchangmin02 2025. 9. 2. 21:36

변수의 값이 바뀌는데도, 텍스트는 안 변하는 이유(참조타입인지,기본타입인지에 따라 값 복사인지, 참조인지로 바뀌니깐)

 

 

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI; // Button을 사용하기 위해
using TMPro;

public class CLickImpulseButton : MonoBehaviour
{

    private int clickedTime;
    public TextMeshProUGUI clickedTimeText;

    public float minX = -388f;
    public float maxX = 372f;
    public float minY = -650f;
    public float maxY = 760f;

    // Start is called before the first frame update
    void Start()
    {
        clickedTime = 0;
        updateClickedTimerText();
    }

    // Update is called once per frame
    void Update()
    {

    }
    void clickedButton()
    {
        clickedTime += 1;
        //근데 이거 업데이트 안해줘도 되나<해줘야겟지?아니면 그냥 int값만바뀌고 화면의 텍스트는 그대로니깐, 근데 이부분이 젤 이해가 안됨
       
    }
    void updateClickedTimerText() {
        clickedTimeText.text = "클릭횟수: " + clickedTime + "번";
    }
}

1. 일반적인 프로그래밍에서의 변수 할당

먼저 제시해주신 예시 코드를 자세히 살펴보겠습니다.

C#
 
int a = 1;
// 1. 메모리에 정수(int) 타입의 변수 'a'를 위한 공간이 생기고, 그 안에 값 1이 저장됩니다.
//    a -> [ 1 ]

String b = a + "임";
// 2. "a + '임'" 이라는 표현식이 먼저 계산됩니다.
// 3. 이 시점에서 a의 값은 '1'입니다. 따라서 "1" + "임" 이 계산되어 "1임" 이라는 새로운 문자열(String)이 만들어집니다.
// 4. 이 결과물인 "1임"이라는 문자열이 메모리에 새로 생기고, 변수 'b'는 이 "1임"을 가리키게(저장하게) 됩니다.
//    b -> [ "1임" ]

a = 2;
// 5. 변수 'a'의 공간에 있던 값 1이 지워지고, 새로운 값 2가 저장됩니다.
//    a -> [ 2 ]
// 6. 여기서 가장 중요한 점은, 이 작업은 변수 'a'에만 영향을 준다는 것입니다.
// 7. 이전에 'a'의 값을 참조해서 만들어졌던 변수 'b'는 이 변경사항을 전혀 알지 못합니다. 'b'는 여전히 "1임"이라는 독립적인 값을 그대로 가지고 있습니다.

print(b);
// 8. 따라서 변수 b에 저장된 값인 "1임"이 출력됩니다.

컴퓨터는 코드를 위에서 아래로, 한 줄씩 순서대로 실행합니다. String b = a + "임"; 이 실행되는 시점 a의 값은 1이었고, 그 계산 결과인 "1임" b에 저장된 후에는 a b는 완전히 별개의 변수가 됩니다. b a를 계속해서 감시하고 있다가 a가 바뀔 때마다 자신의 값을 자동으로 바꾸는 '수식'이나 '연결'이 아니라, 한번 계산된 결과값을 저장한 '상자'일 뿐입니다.

2. 유니티 코드에 적용하기

이제 이 개념을 원래의 유니티 코드에 적용해 보겠습니다.

C#
 
// updateClickedTimerText() 함수 안의 코드
clickedTimeText.text = "클릭횟수: " + clickedTime + "번";

이 코드는 위에서 설명한 String b = a + "임"; 과 완전히 동일한 원리로 동작합니다.

  1. "클릭횟수: " + clickedTime + "번" 이라는 표현식이 먼저 계산됩니다.
  2. 이 코드가 실행되는 시점의 clickedTime 변수 값을 가져옵니다. (예를 들어 clickedTime 0이었다면)
  3. "클릭횟수: " + 0 + "번" 이 계산되어 "클릭횟수: 0번" 이라는 새로운 문자열이 만들어집니다.
  4. 이 완성된 문자열이 clickedTimeText.text 라는 변수(UI 텍스트 컴포넌트의 속성)에 할당(저장) 됩니다.

그 이후에 clickedTime 값이 1로 바뀌거나 10으로 바뀌어도, clickedTimeText.text에 이미 저장된 "클릭횟수: 0번" 이라는 문자열은 자동으로 변하지 않습니다.

clickedTimeText.text clickedTime 변수와 실시간으로 연동되는 마법 같은 존재가 아닙니다. 그저 문자열을 저장하는 평범한 변수일 뿐입니다.

결론

따라서, clickedTime의 값이 바뀐 것을 화면에 다시 보여주고 싶다면,

  1. 바뀐 clickedTime 값을 가지고
  2. "클릭횟수: " + clickedTime + "번" 계산을 다시 실행해서
  3. 새롭게 만들어진 문자열(예: "클릭횟수: 1번")을
  4. clickedTimeText.text 다시 할당(덮어쓰기) 해주는 과정이 반드시 필요합니다.

이 과정을 함수로 만들어 둔 것이 바로 updateClickedTimerText() 이고, 그래서 clickedTime 값이 바뀔 때마다(즉, ClickedButton() 함수가 호출될 때마다) 이 함수를 명시적으로 호출해주어야 하는 것입니다.

 

 

 

 

 

// C# 문법으로 변환한 코드
int a = 1;
List<int> intList = new List<int>(); // C#에서는 List<T>를 사용합니다.
intList.Add(a); // append 대신 Add를 사용합니다.

a = 2;
print(intList[0]); // 결과는 1

그 이유는 int '값 타입' 이기 때문입니다. 이제 왜 그런지, 그리고 언제 다르게 동작하는지 자세히 설명해 드리겠습니다.


1. 값 타입 (Value Type) - '복사해서 주기'

int, float, double, bool, char 그리고 구조체(struct, Unity의 Vector3, Quaternion 등)는 모두 값 타입입니다. 값 타입은 변수에 실제 데이터 값 자체를 담고 있습니다.

이것을 '복사기'에 비유할 수 있습니다.

  1. int a = 1;
    • a라는 상자를 만들고 그 안에 숫자 1을 넣어 둡니다.
    • a 상자: [ 1 ]
  2. intList.Add(a);
    • 리스트에 a를 추가하라는 명령을 받으면, 컴퓨터는 a 상자 안을 들여다봅니다.
    • 안에 1이 있는 것을 보고, 그 1 복사합니다.
    • **복사된 새로운 1**을 리스트의 첫 번째 칸에 넣습니다.
    • a 상자: [ 1 ]
    • intList의 첫 번째 칸: [ 1 ] <- 이것은 a안에 있던 1의 복사본(쌍둥이)입니다.
  3. a = 2;
    • 원래 있던 a 상자의 내용물을 지우고 숫자 2를 넣습니다.
    • a 상자: [ 2 ]
    • 하지만 리스트의 첫 번째 칸에는 이전에 **복사해 두었던 1**이 그대로 남아있습니다. 원본이 바뀐다고 해서 복사본이 자동으로 바뀌지는 않습니다.
  4. print(intList[0]);
    • 따라서 리스트의 첫 번째 칸에 있는 값, 즉 1이 출력됩니다.

2. 참조 타입 (Reference Type) - '주소를 알려주기'

반면에, class로 만든 모든 객체들(Object), List 자체, string 등은 '참조 타입' 입니다. 참조 타입은 변수에 실제 데이터가 아니라, 데이터가 저장된 **메모리의 위치(주소)**를 담고 있습니다.

이것은 '집 주소가 적힌 쪽지'에 비유할 수 있습니다.

이해를 돕기 위해 간단한 Player 클래스를 만들어 보겠습니다.

C#
 
public class Player
{
    public int health; // 플레이어의 체력
}

이제 이 클래스를 사용하면 완전히 다른 결과가 나옵니다.

C#
 
Player player1 = new Player(); // 1. 'player1'이라는 쪽지를 만들고, 새로 지은 Player의 '집 주소'를 적어 넣습니다.
player1.health = 100;         //    그 집에 찾아가서 health 방에 100을 넣습니다.

List<Player> playerList = new List<Player>();
playerList.Add(player1);      // 2. 리스트에 player1을 추가합니다.
                              //    이때 Player라는 집 전체를 복사하는 게 아니라,
                              //    '집 주소가 적힌 쪽지'만 복사해서 리스트에 넣습니다.
                              //    이제 player1과 리스트의 첫 번째 칸은 똑같은 집을 가리킵니다.

player1.health = 50;          // 3. 'player1' 쪽지에 적힌 주소로 집에 찾아가서 health를 50으로 바꿉니다.

// 리스트의 첫 번째 칸에 있는 쪽지로 집에 찾아가서 health를 확인합니다.
// 위에서 이미 50으로 바뀌었으므로 50이 출력됩니다.
print(playerList[0].health); // 결과는 50

player1 playerList[0]은 서로 다른 쪽지(변수)이지만, 같은 집(메모리에 있는 실제 객체)의 주소를 가리키고 있기 때문에, 어느 한쪽을 통해 집의 내용을 바꾸면 다른 쪽에서 확인할 때도 바뀐 내용이 보이게 되는 것입니다.

결론

  • 질문 주신 코드 (List<int>)가 1을 출력하는 이유: int 값 타입이라서 리스트에 추가될 때 값이 복사되었기 때문입니다.
  • 문자열(string)이 불변인 것과는 다른 개념: 문자열이 불변(immutable)이라는 것은 한번 만들어진 문자열 객체의 내용은 수정될 수 없다는 특성입니다. ("안녕" 이라는 문자열을 "안녕!" 으로 바꿀 수 없고, "안녕!"이라는 새로운 문자열이 만들어질 뿐입니다). 이것은 값/참조 타입과는 또 다른 중요한 개념입니다.
  • 유니티에서 텍스트를 업데이트해야 하는 이유: clickedTimeText.text = "클릭횟수: " + clickedTime; 이 코드는 clickedTime(값 타입)을 이용해 새로운 문자열(참조 타입)을 만들어서 text 속성에 할당(대입) 하는 행위입니다. 이 할당이 일어나는 순간의 clickedTime 값이 복사되어 사용될 뿐, 그 이후로 clickedTime text 사이에 지속적인 연결은 없습니다.

이 값 타입과 참조 타입의 차이는 C#과 Java를 포함한 많은 객체 지향 언어에서 굉장히 중요한 기본기이므로, 이번 기회에 확실히 이해해두시면 앞으로 프로그래밍하실 때 정말 큰 도움이 될 것입니다.

 

 

 

1. TextMeshProUGUI.text는 '프로퍼티(Property)'입니다.

먼저, text는 단순한 변수가 아니라 프로퍼티(Property) 입니다. 클래스 외부에서 보면 변수처럼 보이지만, 내부적으로는 값을 설정(set)하거나 가져올(get) 때 특정 메소드(코드 덩어리)를 실행하는 문지기 같은 역할을 합니다.

우리가 이런 코드를 작성하면:

C#
 
clickedTimeText.text = "새로운 값";

실제로는 TextMeshProUGUI 컴포넌트 내부의 set 로직이 호출됩니다. 이 로직은 단순히 문자열 주소를 저장하는 것에서 그치지 않고, "어? 텍스트가 바뀌었네? 그럼 화면에 보이는 글자 메시(mesh)를 새로 계산해서 다시 그려야겠다!" 와 같은 후속 작업을 연쇄적으로 실행합니다.

즉, 값을 할당하는 행위 자체가 '화면을 갱신하라'는 명령이 되는 것입니다.

2. string의 불변성 (Immutability)

이것이 핵심입니다. '불변'이라는 말은 "한번 생성된 문자열 객체는 그 내용을 절대 바꿀 수 없다" 는 뜻입니다.

아래 코드를 봅시다.

C#
 
string greeting = "Hello";
  1. 메모리에 "Hello" 라는 내용을 가진 문자열 객체가 하나 생성됩니다.
  2. greeting 이라는 변수(참조)는 이 객체의 메모리 주소를 가리킵니다.

이제 이 문자열을 바꾸려고 하면 어떻게 될까요?

C#
 
greeting = greeting + " World"; // "Hello" 뒤에 " World"를 붙인다

string이 일반적인 참조 타입이라면, 기존 "Hello" 객체의 내용 뒤에 " World"를 덧붙여서 내용을 바꿀 것 같지만, 절대 그렇게 동작하지 않습니다.

대신 다음과 같이 동작합니다.

  1. 기존의 "Hello" 객체와 " World"를 조합해서 "Hello World" 라는 완전히 새로운 문자열 객체를 메모리에 만듭니다.
  2. greeting 변수는 이제 새로 만들어진 "Hello World" 객체의 주소를 가리키도록 자신의 참조를 바꿉니다.
  3. 원래 있던 "Hello" 객체는 아무도 참조하지 않는 상태가 되어 나중에 가비지 컬렉터(GC)에 의해 메모리에서 정리됩니다.

결과적으로, 내용을 수정하는 것처럼 보여도 실제로는 계속해서 새로운 객체를 만들어 참조를 바꾸는 방식으로 동작합니다. 이는 마치 값 타입에서 값을 복사하여 새로 할당하는 것과 매우 유사하게 보입니다.

결론: 모든 것을 종합하면

clickedTimeText.text = "클릭횟수: " + clickedTime + "번"; 이라는 코드가 실행될 때 일어나는 일은 다음과 같습니다.

  1. "클릭횟수: ", clickedTime의 현재 값(예: 1), 그리고 "번"이 조합되어 "클릭횟수: 1번"이라는 새로운 문자열 객체가 메모리에 생성됩니다.
  2.  새로운 객체의 주소 clickedTimeText.text 프로퍼티의 set 접근자로 전달됩니다.
  3. set 접근자는 이 새로운 문자열을 기반으로 화면의 글자를 다시 그리는(Update/Render) 작업을 트리거합니다.
  4. 그 이후에 clickedTime 값이 2로 바뀌어도, 이미 text 프로퍼티에 할당되어 화면에 그려진 "클릭횟수: 1번" 문자열 객체와는 아무런 상관이 없게 됩니다.
  5. 화면의 내용을 2로 바꾸려면, 이 모든 과정을 다시 반복해서 "클릭횟수: 2번"이라는 또 다른 새 문자열 객체를 만들어 text 프로퍼티에 다시 할당해주어야 합니다.

따라서 TextMeshProUGUI.text 참조 타입(string)이지만, 불변성이라는 특징 때문에 값을 할당할 때마다 새로운 객체가 생성되므로, 데이터가 변경될 때마다 명시적으로 다시 할당하여 화면을 업데이트해주어야 하는 것입니다.

 

 

 

 

 

 

1. 메모리 및 성능 효율성 (참조 타입의 장점)

문자열은 int bool처럼 크기가 고정된 데이터가 아닙니다. 한 글자일 수도 있고, 책 한 권의 내용 전체일 수도 있습니다.

여기에 "무거운 백과사전" 을 비유해 보겠습니다.

  • 만약 문자열이 값 타입(Value Type)이었다면?
    • 값 타입은 데이터를 전달할 때마다 내용 전체를 복사합니다.
    • 이는 누군가에게 백과사전의 내용을 알려줄 때마다, 1000페이지짜리 백과사전 전체를 복사해서 새 책을 만들어주는 것과 같습니다.
    • 메소드에 문자열을 전달하거나, 다른 변수에 할당할 때마다 엄청난 메모리 복사가 일어나고, 프로그램은 매우 느려질 것입니다.
  • 그래서 문자열은 참조 타입(Reference Type)입니다.
    • 참조 타입은 데이터의 실제 위치(메모리 주소)만 전달합니다.
    • 이는 백과사전이 도서관 어디에 있는지 알려주는 "도서 카드(주소)" 만 건네주는 것과 같습니다. 도서 카드를 복사하는 것은 백과사전 전체를 복사하는 것보다 비교할 수 없을 정도로 빠르고 가볍습니다.
    • 이렇게 하면 아무리 긴 문자열이라도 빠르고 효율적으로 여러 곳에서 사용할 수 있습니다.
    ⭐ 문자열 공유 (String Interning/Pooling)
    "Hello" 라는 문자열을 코드 100군데에서 사용한다고 가정해 봅시다. 참조 타입이기 때문에 컴파일러는 똑똑하게 메모리에 "Hello" 객체를 단 하나만 만들어두고, 100개의 변수가 모두 그 하나의 객체를 참조(공유)하게 만듭니다. 이는 엄청난 메모리 절약입니다.

2. 안정성과 예측 가능성 (불변성의 장점)

그런데 여기서 문제가 생깁니다. 모두가 똑같은 백과사전(같은 메모리 주소)을 참조하고 있는데, 만약 누군가 한 명이 그 백과사전의 내용을 마음대로 수정할 수 있다면 어떻게 될까요? (예: "태양계" 항목을 "달걀계"로 바꿔버린다면?) 다른 모든 참조자들은 의도치 않게 바뀐 내용을 보게 되어 대혼란이 발생할 것입니다.

이러한 부작용(Side Effect) 을 막기 위해 불변(Immutable) 이라는 강력한 규칙을 추가한 것입니다.

  • "참조는 하되, 수정은 절대 불가"
    • string을 불변으로 만듦으로써, 한 번 생성된 문자열의 내용은 그 누구도 바꿀 수 없도록 보장합니다.
    • 문자열을 수정하는 것처럼 보이는 모든 작업(예: str = str + "!")은 사실 기존 문자열을 바꾸는 것이 아니라, 수정된 내용으로 새로운 문자열 객체를 만드는 것입니다. 그리고 변수는 그 새로운 객체를 참조하게 됩니다.
    • 이는 도서관의 규칙이 "백과사전은 열람만 가능하며, 내용을 바꾸고 싶으면 기존 백과사전을 통째로 복사해서 새 버전을 만든 후 그 책에 수정하시오" 와 같은 것과 같습니다.

불변성이 주는 구체적인 장점은 다음과 같습니다.

  • 예측 가능성: 메소드에 문자열을 넘겼을 때, 그 메소드 내부에서 무슨 짓을 하든 원본 문자열이 변경되지 않을 것이라는 100% 확신을 가질 수 있습니다.
  • 스레드 안전성(Thread Safety): 여러 스레드가 동시에 같은 문자열 데이터에 접근해도, 어차피 데이터를 바꿀 수 없으므로 데이터가 꼬일 걱정이 전혀 없습니다. (수정 가능한 객체였다면 '락(Lock)' 같은 복잡한 동기화 처리가 필요합니다.)
  • 보안: 파일 경로, 네트워크 주소, 암호화 키 등 중요한 정보를 문자열로 다룰 때, 한번 생성된 이후 다른 곳에서 임의로 변경될 위험이 없습니다.
  • 컬렉션 키로 사용 가능: Dictionary HashSet의 키로 문자열을 안전하게 사용할 수 있습니다. 만약 키로 사용된 문자열이 나중에 변경된다면, 해시값이 바뀌어 데이터를 찾지 못하는 문제가 발생할 것입니다.

결론: 최상의 조합

정리하자면, 자바와 C#의 string 설계자들은 다음과 같은 절묘한 조합을 선택한 것입니다.

참조 타입으로 만들어 메모리와 성능을 확보하고,
동시에 불변(Immutable) 속성을 부여하여 참조 공유 시 발생할 수 있는 모든 부작용과 위험을 원천 차단하자!

그 결과, string은 개발자가 사용하기에는 마치 간단한 값 타입처럼 느껴지지만, 내부적으로는 참조 타입의 효율성과 불변 객체의 안정성을 모두 갖춘 매우 정교하고 강력한 타입이 되었습니다.