Carrot
본문 바로가기
Unity/멋쟁이사자처럼 부트캠프

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(55일차) - C# 중급 (3), LINQ

by 독기품은토끼 2025. 8. 5.
✅ 오늘의 학습 목표
1. C# 중급 (3)

1. Action 복습

1. Callback

Action 델리게이트를 활용하면 특정 타이밍에 실행할 여러 작업을 미리 정의하고 한 번에 처리할 수 있다.

using System;
using System.Collections;
using UnityEngine;

public class StudyCallback : MonoBehaviour
{
    public Action bombAction;

    void OnEnable()
    {
        bombAction += () =>
        {
            BombExplosition();
            BombDamage();
            BombEffect();
        };
    }

    IEnumerator Start()
    {
        Debug.Log("폭탄 타이머 시작");
        yield return new WaitForSeconds(5f);

        bombAction?.Invoke();
    }

    private void BombExplosition()
    {
        Debug.Log("폭발 실행");
    }

    private void BombDamage()
    {
        Debug.Log("폭발 데미지 적용");
    }

    private void BombEffect()
    {
        Debug.Log("폭발 이펙트 실행");
    }
}

 

폭탄이 터질 때 실행할 행동들을 미리 정의해 두고 일정 시간이 지난 뒤 Action을 통해 한 번에 호출하도록 했다.
이처럼 나중에 실행할 행동을 미리 저장해두고 특정 시점에 호출하는 구조가 바로 콜백이다.

 

콜백을 사용하는 이유는

  • 어떤 타이밍에 어떤 작업을 실행할지 유연하게 설정할 수 있고
  • 타이밍을 기다리는 쪽과 실제 행동하는 쪽을 분리(디커플링)할 수 있고
  • 나중에 외부에서 행동을 추가하는 것도 가능하다.

예를 들어 UI 매니저를 통해 폭탄을 던진 횟수를 UI에 표시한다고 가정한다면,
콜백 구조 덕분에 bombAction에 아래처럼 한 줄만 추가하면 된다.

bombAction += () => UIManager.Instance.ShowBombMessage();

 

2. Decoupling

디커플링이란 클래스가 다른 클래스에 직접 의존하지 않도록 구조를 설계하는 것을 뜻한다.

using System;
using System.Collections;
using UnityEngine;

public class SwingController : MonoBehaviour
{
    public GameManager gameManager;

    private Animator anim;

    public Action onStartSwing;
    public Action onEndSwing;

    private bool isSwing;

    void Start()
    {
        anim = GetComponent<Animator>();

        onStartSwing += SwingStart;
        onEndSwing += SwingEnd;
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            if (!isSwing)
                StartCoroutine(SwingRoutine(onStartSwing, onEndSwing));
        }
    }

    IEnumerator SwingRoutine(Action action1, Action action2)
    {
        isSwing = true;
        anim.SetTrigger("Swing");

        onStartSwing?.Invoke();

        // float animLength = anim.GetCurrentAnimatorClipInfo(0).Length;
        // float animLength = anim.GetCurrentAnimatorStateInfo(0).length; // 현재 실행 중인 애니메이션의 길이(시간)
        yield return new WaitForSeconds(0.5f);

        onEndSwing?.Invoke();
        isSwing = false;
    }

    private void SwingStart()
    {
        Debug.Log("스윙 시작");
    }

    private void SwingEnd()
    {
        Debug.Log("스윙 종료");
    }
}

 

 

스윙 시작 시점과 끝나는 시점에 각각 필요한 행동을 분리하기 위하여 Action 델리게이트를 활용해 주었고,

이런 구조로 작성하면 추후에 외부 클래스에서도 아래처럼 콜백을 추가할 수 있다.

swingController.onStartSwing += () => SoundManager.Instance.Play("swing");
swingController.onEndSwing += () => UIManager.Instance.ShowCooldown();

 

SwingController는 사운드 매니저나 UI 매니저를 몰라도

스윙 타이밍에 필요한 기능을 외부에서 유연하게 추가할 수 있다는 뜻이다.

 

코루틴 SwingRoutine()에서는 Action을 통해 등록된 함수들을 타이밍에 맞게 호출한다.

즉, 스윙 타이밍은 내부에서 제어하되, 실행할 행동은 외부에서 구현한 구조이다. → 디커플링

 

3. Interface를 활용한 Decoupling

유니티로 게임 구조를 짜다 보면 한 객체가 다른 객체에 직접 접근하는 경우가 자주 생긴다.

// 커플링 문제점이 있는 코드
using UnityEngine;

public class StudyDecoupling : MonoBehaviour
{
    public class Player
    {
        public Enemy enemy;

        public void AttackEnemy()
        {
            enemy.TakeDamage(10f);
        }
    }

    public class Enemy
    {
        public float health = 10f;

        public void TakeDamage(float damage)
        {
            health -= damage;
        }
    }
}
 

위 코드를 살펴보면 Player가 Enemy 클래스에 있는 함수를 호출하면서 공격을 가하고 있다.

이렇게 의존하는 관계를 커플링(coupling)이라고 부르는데 커플링 관계가 많으면 유지보수가 어려워지고 확장성도 떨어진다.

 

예를 들어, 나중에 Enemy가 아닌 BossEnemy, 또는 건물, 오브젝트 등 다른 걸 공격하고 싶어지면

일일이 Player 클래스 코드를 바꿔야 한다는 것이다.

 

그래서 등장하는 게 디커플링(Decoupling)이고 디커플링은 말 그대로 결합을 느슨하게 만든다는 의미이다.

using UnityEngine;

// 커플링(의존도) 해결을 위해 Interface 활용
public interface IDamageable
{
    void TakeDamage(float damage);
}

public class StudyDecoupling2 : MonoBehaviour
{
    public class Player
    {
        public void AttackEnemy(IDamageable target, float damage)
        {
            target.TakeDamage(damage);
        }
    }

    public class Enemy : MonoBehaviour, IDamageable
    {
        public float health = 10f;

        public void TakeDamage(float damage)
        {
            health -= damage;
        }
    }
}

 

이제 Player는 Enemy라는 구체적인 클래스가 아니라 IDamageable이라는 기능(인터페이스)에 의존하게 된다.

추후에 BossEnemy, Object, Building 등 다양한 공격 대상이 생겨도 Player 코드는 그대로 두고 새로운 대상만 추가하면 된다.

 

 

2. LINQ (Language Intergrated Query)

Linq는 Collection(리스트, 큐 등) 중에서 특정 조건을 대상으로 선별하는 기능이다.

-- 사용 예시
var result = from 변수 in Collection
             where 조건
             select 조건을 통과한 대상

 

1. Linq 기본 동작

Linq를 학습하기 전에 우리가 예전에 배웠던 IEnumerator에 대해 이야기해보겠다.

using System.Collections;
using UnityEngine;

public class StudyCoroutine : MonoBehaviour
{
    IEnumerator enumerator;

   void Start()
    {
        enumerator = Numbers();
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            enumerator.MoveNext();
            var result = enumerator.Current;
            Debug.Log(result);
        }
    }

    IEnumerator Numbers()
    {
        yield return 3;
        yield return 5;
        yield return 7;
    }
}

 

해당 코드는 코루틴을 수동으로 돌려보내는 예제인데

여기서 핵심은 Coroutine을 사용하면서 MoveNext()로 값을 하니씩 꺼내는 구조라는 점이다.

 

즉, yield return으로 미리 값을 다 넘기는 게 아니라 필요할 때마다 하나씩 꺼내서 반환한다. 이게 바로 지연 실행 구조다.

수업 시간에는 Linq도 이와 비슷한 구조로 작동한다고 하셨다.

정확히는 Linq도 내부적으로 IEnumerator를 사용하고, MoveNext로 순회하면서 필요한 값을 꺼낸다는 말이었다.

 

using System.Linq;
using UnityEngine;

public class StudyLinq : MonoBehaviour
{
    public int[] numbers = { 1, 2, 3, 4, 5 };

    void Start()
    {
        // IEnumerable 표현 방식
        var result = from number in numbers
                     where number > 3
                     select number;

        // Lambda 표현 방식
        var result2 = numbers.Where (n => n > 3);
        var result3 = numbers.Select(n => n * n);

        foreach (var n in result)
            Debug.Log(n);
    }
}

 

Linq는 Sql문법과 비슷하게 작성되고 위 코드에서 result, result2, result3는 바로 반환되지 않는다.

Start() 함수가 실행되고 foreach로 순회할 때 그제야 조건에 맞는 값들이 하나씩 꺼내진다.

 

즉, Linq도 IEnumerator 기반으로 동작하고 있고 foreach나 .ToList()를 쓸 때마다 MoveNext로 루프 돌리며 조건 체크하고 Current 꺼내는 식으로 작동한다는 것이다.

 

2. List 활용

using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class StudyLinq2 : MonoBehaviour
{
    [System.Serializable]
    public class Person
    {
        public string name;
        public int score;

        public Person(string name, int score)
        {
            this.name = name;
            this.score = score;
        }
    }

    public List<Person> persons = new List<Person>();

    public int cutline = 70;

    void Start()
    {
        persons.Add(new Person("John", 65));
        persons.Add(new Person("Sarah", 80));
        persons.Add(new Person("David", 95));
        persons.Add(new Person("Emily", 70));
        persons.Add(new Person("Michael", 50));

        CheckScore();
    }
    
    private void CheckScore()
    {
        #region Linq 사용 X
        // 과목이 나뉘거나 추가 가공을 하게 되면 번거로움 발생
        foreach (var person in persons)
        {
            if (person.score > cutline)
                Debug.Log($"{person.name} 통과");
            else
                Debug.Log($"{person.name} 탈락");
        }

        #endregion
        
        #region Linq 사용 O
        //var passPersons = from person in persons
        //                  where person.score >= cutline
        //                  select person;

        // 람다 사용
        var passPersons = persons.Where(p => p.score > cutline);
        var failPersons = persons.Except(passPersons);

        foreach (var p in passPersons)
            Debug.Log($"<color=green>{p.name}</color>");
        foreach (var p in failPersons)
            Debug.Log($"<color=red>{p.name}</color>");

        #endregion
    }
}

 

System.Serializeable

System.Serializable은 구현한 클래스를 Unity 에디터에서 인스펙터를 통해 다룰 수 있도록 해주는 키워드다.

수시로 변경되는 데이터를 다룰 때 매번 스크립트 내부에서 직접 수정하는 것보다 인스펙터에서 값을 수정할 수 있어 훨씬 효율적이다.
리스트 형태의 데이터도 편하게 확인하고 수정할 수 있기 때문에 테스트나 디버깅 작업에도 자주 활용된다.

 

Linq 없이 조건문으로만 처리할 때의 단점

데이터 양이 적을 때는 foreach나 if 같은 조건문만으로도 충분히 처리할 수 있다.
하지만 조건이 복잡해지거나 필터링한 데이터를 여러 번 가공하거나 재사용해야 할 경우 코드가 길어지고 유지보수가 어려워질 수 있다.

이런 경우에는 Linq를 활용해서 조건 필터링이나 데이터 분류를 더 간결하게 처리하는 게 효과적이다.

 

Lambda

Linq를 사용한 코드를 더 간략하게 구현하기 위해서 Lambda를 활용할 수 있다.

필요한 조건만 필터링할 수 있기 때문에 Where, Select, OrderBy 같은 Linq 메서드들과 함께 자주 쓰인다.

 

3. 정렬

using System.Linq;
using UnityEngine;

// 정렬 실습
public class StudyLinq3 : MonoBehaviour
{
    public int[] numbers = { 1, 2, 3, 4, 5 };

    void Start()
    {
        var result = from number in numbers
                     where number > 1
                     orderby number descending
                     select number;

        // 람다 사용
        var result2 = numbers.Where(n => n > 1).OrderByDescending(n => n);

        foreach (var number in result)
            Debug.Log(number);
    }
}

 

Linq의 orderby 키워드를 사용하면 원하는 기준에 따라 데이터를 정렬할 수 있다.

기본값은 오름차순(ascending)이며 내림차순으로 정렬하고 싶다면 descending 키워드를 추가해 주면 된다.

 

람다 방식에서도 OrderByDescending 메서드를 사용해서 동일한 정렬이 가능하다.

숫자뿐 아니라 문자열, 클래스의 필드 등 원하는 기준으로 정렬할 수 있기 때문에 다양한 데이터 가공에 활용할 수 있다.

 

4. 내부 조인 (Inner Join)

여러 개의 특정 데이터를 각각 따로 리스트로 관리하고 있을 때 두 데이터를 연결해서 함께 사용해야 하는 경우가 있다.

 

아래 코드로 예시를 들면

학생 이름과 학생의 시험 점수를 같이 출력하고 싶다면 단순히 리스트 하나만 순회해서는 안된다.

이럴 때에는 두 데이터를 기준값으로 연결해 주는 조인 작업이 필요하다.

 

using System.Collections.Generic;
using System.Linq;
using UnityEngine;

// Inner Join 실습
public class StudyLinq4 : MonoBehaviour
{
    #region Data Class
    [System.Serializable]
    public class Student
    {
        public int studentID;
        public string studentName;

        public Student(int studentID, string studentName)
        {
            this.studentID = studentID;
            this.studentName = studentName;
        }
    }

    [System.Serializable]
    public class Grade
    {
        public int studentID;
        public int score;
        public string subject;

        public Grade(int studentID, int score, string subject)
        {
            this.studentID = studentID;
            this.score = score;
            this.subject = subject;
        }
    }
    #endregion

    public List<Student> students = new List<Student>();
    public List<Grade> grades = new List<Grade>();

    void Start()
    {
        #region Add Data
        students.Add(new Student(1, "Alice"));
        students.Add(new Student(2, "Bob"));
        students.Add(new Student(3, "Charlie"));
        students.Add(new Student(4, "Eve"));

        grades.Add(new Grade(1, 90, "Math"));
        grades.Add(new Grade(2, 85, "Science"));
        grades.Add(new Grade(3, 92, "English"));
        grades.Add(new Grade(4, 76, "Math"));
        #endregion

        InnerJoin();
    }

    private void InnerJoin()
    {
        var innerJoin = from student in students
                        join grade in grades
                        on student.studentID equals grade.studentID
                        select new
                        {
                            StudentID = student.studentID,
                            StudentName = student.studentName,
                            Subject = grade.subject,
                            Score = grade.score
                        };

        foreach (var person in innerJoin)
            Debug.Log($"ID : {person.StudentID} / Name : {person.StudentName} / Subject : {person.Subject} / Score : {person.Score}");
    }
}

 

 

Linq에서는 join 키워드를 사용해서 두 컬렉션(리스트)을 특정 키(studentID) 기준으로 묶을 수 있다.

이 구조는 데이터베이스의 Inner Join과 동일한 개념이다.

 

즉, 두 리스트를 순회하면서 studentID가 같은 항목끼리 연결한 뒤 그 결과만 추려내는 방식이다.

 

이런 식으로 조인을 사용하면 분리되어 있는 특정 데이터를 통합해서 꺼낼 수 있고

따로 foreach를 중첩해서 조건을 걸고 비교하는 번거로운 작업 없이 데이터를 연결할 수 있다.

 

5. 외부 조인 (Outer Join)

Inner Join은 기본적으로 공통된 키(studentID)가 양쪽 리스트에 모두 존재하는 경우에만 결과를 반환한다.

공통된 키가 없더라도 모든 데이터를 나타내게 하고 싶다면 Outer Join을 활용하면 된다.

using System.Collections.Generic;
using System.Linq;
using UnityEngine;

// Inner Join 실습
public class StudyLinq5 : MonoBehaviour
{
    #region Data Class
    [System.Serializable]
    public class Student
    {
        public int studentID;
        public string studentName;

        public Student(int studentID, string studentName)
        {
            this.studentID = studentID;
            this.studentName = studentName;
        }
    }

    [System.Serializable]
    public class Grade
    {
        public int studentID;
        public int score;
        public string subject;

        public Grade(int studentID, int score, string subject)
        {
            this.studentID = studentID;
            this.score = score;
            this.subject = subject;
        }
    }
    #endregion

    public List<Student> students = new List<Student>();
    public List<Grade> grades = new List<Grade>();

    void Start()
    {
        #region Add Data
        students.Add(new Student(1, "Alice"));
        students.Add(new Student(2, "Bob"));
        students.Add(new Student(3, "Charlie"));
        students.Add(new Student(4, "Eve"));
        students.Add(new Student(5, "Frank"));

        grades.Add(new Grade(1, 90, "Math"));
        grades.Add(new Grade(2, 85, "Science"));
        grades.Add(new Grade(3, 92, "English"));
        grades.Add(new Grade(4, 76, "Math"));
        grades.Add(new Grade(6, 90, "History"));
        #endregion

        OuterJoin();
    }

    private void OuterJoin()
    {
        var leftOuterJoin = from student in students
                            join grade in grades
                            on student.studentID equals grade.studentID into studentGrades
                            from grade in studentGrades.DefaultIfEmpty()
                            select new
                            {
                                StudentID = student.studentID,
                                StudentName = student.studentName,
                                Subject = grade?.subject,
                                Score = grade?.score ?? 0 // int 타입은 null 값을 넣을 수 없음
                            };

        var rightOuterJoin = from grade in grades
                             join student in students
                             on grade.studentID equals student.studentID into gradeStudents
                             from student in gradeStudents.DefaultIfEmpty()
                             where student == null
                             select new
                             {
                                 StudentID = grade.studentID,
                                 StudentName = "N/A",
                                 Subject = grade.subject,
                                 Score = grade.score
                             };

        var outerJoin = leftOuterJoin.Union(rightOuterJoin);

        foreach (var person in outerJoin)
            Debug.Log($"ID : {person.StudentID} / Name : {person.StudentName} / Subject : {person.Subject} / Score : {person.Score}");
    }
}

 

Outer Join은 Inner Join과 다르게 into 구문과 DefaultEmpty()를 같이 사용해주어야 한다.

 

1. Left Outer Join → students 기준

Left Outer Join은 왼쪽 리스트(학생)를 기준으로 삼고,
오른쪽 리스트(성적)에 연결되는 항목이 없더라도 왼쪽 항목을 무조건 결과에 포함시키는 방식이다.

 

  • into는 조인 결과를 그룹 형태로 만들어준다.
    → 즉, studentID가 같은 항목들을 묶어서 컬렉션으로 반환한다.
  • 이후 from ... in ... DefaultIfEmpty()를 사용하면,
    → 해당 그룹이 비어있을 경우에는 null을 대신 넣어준다.

결과적으로 학생 한 명씩 순회하면서

해당 학생과 연결되는 성적이 있으면 해당 성적이 들어오고 없으면 null이 들어오게 된다.

 

🚨 주의할 점 - null 체크는 필수

이 구조에서는 성적 정보가 없는 학생도 결과에 포함되기 때문에,
grade.subject나 grade.score에 접근할 때 null 여부를 항상 고려해야 한다.

  • grade?.subject → grade가 null일 경우에도 에러 없이 넘어가도록 처리
  • grade?.score ?? 0 → 점수가 없을 경우 0점을 기본값으로 설정

이렇게 null-safe한 접근 방식이 반드시 들어가야 에러 없이 동작할 수 있다.


 

2. Right Outer Join → grades 기준

Right Outer Join은 반대로 오른쪽 리스트(성적)를 기준으로 삼는다.
즉, 성적은 있는데 학생 정보가 없는 경우까지도 결과에 포함되게 만든다.

  • 먼저 grades를 기준으로 students와 join ... into로 그룹을 만든다.
  • DefaultIfEmpty()를 통해 연결된 학생이 없으면 null을 반환하게 한다.
  • 이후 결과 중에서 student == null인 항목만 따로 필터링해서,
    → 성적은 있지만 학생 정보가 없는 케이스만 추려낸다.

이렇게 하면 성적만 존재하는 외부 데이터를 따로 뽑아낼 수 있고
 student == null인 경우에는 studentName을 "N/A" 같은 기본값으로 직접 넣어주는 방식으로 처리하면 된다.


 

3. Left + Right Outer Join = Full Outer Join

마지막으로 위에서 만든 Left Outer Join 결과와 Right Outer Join 결과를 합쳐주면
양쪽 모두 포함하는 완전한 Outer Join(Full Outer Join) 형태가 된다.

이때는 단순히 두 쿼리 결과를 .Union()으로 합쳐주기만 하면 된다.

참고로 Union()은 중복을 제거한 후 결과를 반환한다.

 

🚨 주의할 점 - 변수 타입

Union은 두 컬렉션의 요소 타입이 완전히 같아야 한다.