이전 파트

2024.09.06 - [공부/채팅 서버] - 채팅 서버 + DB - Part1 Receive,Send

 

채팅 서버 + DB - Part1 Receive,Send

구버전2024.06.18 - [공부] - 채팅 서버 이번엔 전에 만들었던 야매 채팅 서버를 어느정도 잘 짜여진 구조로 만들어 보았다. 구조는 Rookis 님의 C# 게임 서버 구조를 가져와서 변형하여 사용하였다. Ses

ggms-gukhyun.tistory.com

 

이번엔 저번 글에 이어서 채팅 서버의 DBManager, Listener, Connector에 대해 설명하도록 하겠다.

DBManager

DBManager가 하는 역할은 DB의 ip 중복 검사, ip에 맞는 이름 제공, 클라이언트 정보 입력 세가지가 있다.

일단 먼저 데이터베이스와 연결을 해주고 연결할 테이블의 이름을 저장한다.

MySqlConnection은 DB와 연결을 해주는 클래스이고 MySqlDataReader는 명령어를 실행시켜 주는 클래스이다.

SearchDuplication

ip 를 입력받아서 테이블에 중복이 있는지 검사한 후 결과를 리턴해준다.

InsertClientInfo

ip, name 을 입력받아서 데이터베이스에 Insert 해준다. 

만약 테이블이 닫히지 않았다면 닫아준다.

GetClientName

이것도 역시 테이블이 닫히지 않았다면 닫아주고 ip를 받아서 ip에 맞는 이름을 가져온 후 리턴한다.

Listener

Listener 클래스는 서버가 클라이언트를 Accept 하는 작업을 도와주는 클래스이다.

Init

Listener에서는 처음에 끝점과 세션을 받는Func, 세션을 보내는 Action, 이름을 받았는지 확인하는 Func를 받아준다.

클라이언트들을 받아줄 listenSocket을 생성해주고 비동기로 Accept 해줄 것이기 때문에 SocketAsyncEventArgs도 생성해준다.

Accept 역시 Receive와 동일하게 언제 클라이언트들이 서버에 연결할지 모르니 계속 받아주는 상태를 유지해준다.

RegisterAccept

전에 받아줬던 소켓이 남아있을 수 있으니 AcceptSocket, clientSocket을 null로 설정 해준 후 비동기로 받아준다.

OnAcceptCompleted

완료시 실행되는 함수에선 에러가 안났는지 확인 후 새로운 Session을 받아준다. new 로 해주지 않는 이유는 서버를 보면 알 것이다.

clientSocket을 AcceptSocket 으로 설정해주고 우리는 ip마다 이름을 DB에 저장해준 후 가져와주기 때문에 이름이 제대로 가져와 졌는지 확인해준다.

이름이 가져와 졌다면 방금 받아왔던 Session을 init 해주고 Session을 다시 서버에 보내준다. 이러는 이유는 서버에서 연결되어있는 클라이언트들에게 BroadCast를 날리기 위해서 이다.

그 이후 다시 Accept를 받는 상태로 돌아간다.

Connector

Connector 클래스는 Listener와 반대로 클라이언트의 서버 연결을 도와주는 클래스이다.

Connector도 Listener와 같이 끝점과 Session을 반환받는 Func를 Init때 받아준다.

Listener와 다르게 RemoteEndPoint와 UserToken이라는 것이 나온다.

RemoteEndPoint는 비동기 작업을 할때 연결하는 끝점을 나타내고 UserToken은 넘겨주고 싶은 개체를 저장한다.

 저장해둔 Socket을 비동기로 연결해주는데 이때 연결되는 끝점이 RemoteEndPoint이다.

오류가 나지 않았다면 Session개체를 생성해주고 Init 해준다. 그리고 클라이언트에서도 사용할 수 있도록 public으로 된 session변수에 저장해준다.

 Connect는 한번만 해주는 작업이기 때문에 완료가 되면 다시 RegisterConnect 함수를 다시 호출해주지 않는다.

이번 글을 마치며

2달간 학교 수학 공모전 프로젝트를 하느라 블로그 글을 정말 쓰지 못했다.. 블로그가 너무 띄엄띄엄 올라오더라도 놀고 있다고 생각하지 않아줬으면 한다..

다음 파트까지 정리를 마치면 Rokiss 님의 강의를 마저 들은 후 게임 서버를 직접 구현하여 멀티 게임을 만들어 블로그에 정리하는 것을 목표로 하고 있다. 아직까지 한번도 올리지 않은 잡담 글도 그때쯤이면 올리지 않을까 싶다.(엔진 팀프가 존재하지만..) 지금까지 하지 못했던 개인적인 고찰이나 생각들도 점점 올리기 시작하겠다.

 

2024/10/14

Client 와 Server 에서 아주 큰 문제를 발견했다..

현재 서버랑 리스너에서 이름을 가져오는 부분이 멀티쓰레드 환경에서 아주 큰 문제를 발생시킬 수 있는 문제가 발견되었다. 이걸 만들 적에는 패킷을 배우기 전이어서 이름을 구분하는 게 없다.. 그래서 이름을 받아오는 부분을 좀 이상하게 코드를 짰는데 고칠 방도가 도저히 생각나지 않아 이 채팅 서버는 여기서 마무리 해야할 것 같다.

아마 기말고사가 모두 끝난 12월 즈음에 윈폼을 사용하여 정말 제대로된 채팅 서버를 만들어 볼 것 같다. 물론 게임 서버를 만드는 것을 최우선으로 끝낸 뒤 말이다.

정말 죄송합니다.. 

'공부 > 채팅 서버' 카테고리의 다른 글

채팅 서버 + DB - Part1 Receive,Send  (0) 2024.09.09
채팅 서버  (0) 2024.06.18

구버전

2024.06.18 - [공부/채팅 서버] - 채팅 서버

 

이번엔 전에 만들었던 야매 채팅 서버를 어느정도 잘 짜여진 구조로 만들어 보았다. 구조는 Rookis 님의 C# 게임 서버 구조를 가져와서 변형하여 사용하였다. Session, (DBManager, Listener, Connector), (Client, Server) 로 나누어서 세번에 걸쳐 설명하도록 하겠다.

세션

세션에서는 비동기로 버퍼를 주고받는 작업을 실행해주는 클래스이다. 채팅 서버의 세션 클래스에선 Send, Receive 를 담당하고 있다

ReceiveBuffer

Receive 를 설명하기 전 먼저 ReceiveBuffer를 설명하겠다.
ReceiveBuffer 는 버퍼를 재활용 하기 위해 설계된 버퍼로 이미 읽은 길이를 나타내는 readPos와 데이터가 들어와있는 위치를 나타내는 writePos가 존재한다.
 
변수를 먼저 보면

매서드는 

그림으로 설명하자면

처음엔 readPos와 writePos 모두 처음을 가리킨다.

데이터가 들어오면 크기만큼 writePos를 옮긴다. 데이터는 있지만 아직 읽지 않은 부분이 DataSegment 가 되고 아직 데이터가 더 들어갈 수 있는 부분이 RecvSegment가 된다.
RecvSegment의 크기가 FreeSize 이다.

데이터를 처리하고 처리한 크기만큼 readPos를 옮긴다. 이때 DataSegment 는 null, DataSize 는 0이 된다.
데이터를 사용하고 Clear 매서드를 사용하여 readPos와 writePos를 처음 위치로 당겨온다.
 
이런 작업을 반복하여 버퍼를 주고받을 때마다 새로만드는 것이 아닌 기존의 버퍼를 재활용 하여 효율적으로 메모리를 사용할 수 있다.
 

Receive

Session 클래스 객체를 생성해주면서 연결된 소켓을 전달해주고 인스턴스 변수에 저장해준다.
SocketAsyncEventArgs 클래스는 비동기 송수신 작업을 담당하는 클래스로 비동기 작업이 끝났을때 호출되는 Completed 이벤트에 작업이 끝내고 해줄 처리를 구독시켜준다.
Receive 는 서버가 언제 버퍼를 보낼지 모르기 때문에 계속해서 호출해줄 것이다.

RegisterReceive

새로운 값을 받을 때마다 버퍼를 초기화 해 주고 _recvArgs 로 받아줄 버퍼를 recvBuffer 의 남은 버퍼로 설정을 해준다.
ReceiveAync 매서드로 recvArg 의 SetBuffer 해준 버퍼에 값을 비동기로 받아준다.
여기서 pending 은 작업이 비동기로 완료됐는지 동기로 완료되었는지를 알려주며 pending 이 false 라면 위에서 구독해준 completed 이벤트가 발행되지 않는다. 그렇기 때문에 직접 OnRecvCompleted 함수를 호출시켜준다.

OnRecvCompleted

받은 버퍼의 바이트 수가 0보다 크고 소켓 에러가 나지 않았다면 완료된 처리를 해준다.
받은 버퍼의 바이트 수만큼 writePos를 옮겨주고 FreeSize, 즉 남은 공간이 받은 바이트 수보다 작다면 취소한다.
OnRecv 매서드는 받은 버퍼를 처리하는 매서드로 처리한 버퍼의 수를 리턴한다. 처리한 길이를 받아주고 readPos를 처리한 길이만큼 당겨준다.
받는 작업이 모두 끝났으므로 다시 받는 매서드를 실행시켜 준다.

Send

Receive 와는 다르게 Send 에선 따로 SendBuffer를 만들어 주지 않았다.  차피 채팅 서버니까 보내줄 내용이 그리 복잡하지 않아서..

Receivve에서 해준 것처럼 completed 이벤트에 끝났을 때 호출할 함수를 구독시킨다. 여기서 Receive와 다른점은 Receive는 언제 받을지 모르기 때문에 상시 호출시켜 처리해주었지만 Send는 원하는 타이밍에 호출하고 보내줄 버퍼를 설정해주어야 하기 때문에 미리 호출해주지 않는다.

Send를 해줄때 sendQueue와 pendingList라는 것을 사용하는데 보내는 과정을 설명하겠다.

SendQueye가 차면 pendingList에 대기시킨 후 비동기로 보낸다.

보내는 과정에 SendQueue는 계속해서 입력을 받아줌

다 보낸 후 sendQueue에 들어온 입력이 있다면 다시 대기시킨 후 보내기를 반복한다.
 
코드를 보면

buffer를 큐에 넣어주고 대기중인 버퍼가 없다면 RegisterSend를 호출한다.
여기서 lock을 해주는 이유는 비동기로 받아주기 때문에 sendQueue를 한 스레드에서만 건들여 주어야 하기 때문이다.

RegisterSend에선 대기 리스트에 큐에 받아온 값을 넣어주고 보내줄 버퍼 리스트를 설정해주고 보내준다.
여기서 OnSendCompleted 가 실행되기 전에는 sendQueue에 값이 들어올 수 있다. 위 2번째 그림 상황인 것이다.

보내는게 완료되면 다시 lock을 걸어주고 작업이 오류없이 완료되었다면 이미 보낸 버퍼 리스트를 초기화 시켜준 후 pendingList도 초기화 해준다. 만약 완료되기 전 들어온 값들이 있다면 다시 RegisterSend를 호출한다. OnSend를 호출시켜주어야 하는데 아직 보내줄 것이 명확하지 않기 때문에 호출해주지 않았다.
 
이렇게 해서 Session의 Receive와 Send가 끝났다 다음엔 DBManager, Listener, Connector로 돌아오겠다.

'공부 > 채팅 서버' 카테고리의 다른 글

채팅 서버 + DB - Part2 DBManager, Listener, Connector  (1) 2024.10.01
채팅 서버  (0) 2024.06.18

저번 시간에 이어 lock을 구현하는 방법에 대하여 더 알아보도록 하겠다.

 

AutoResetEvent

AutoResetEvent는 스레드의 작업이 완료되었을 때 다른 스레드에게 알려주는 역할을 하는 이벤트이다. 

WaitOne 매서드를 사용하여 잠근 후 Set 매서드를 사용하여 잠금을 풀어주는데 잠금을 풀 때 WaitOne 매서드에서 멈춰있는 다른 스레드들에게 이벤트를 호출하여 지금 잠금이 풀렸다고 알려준다.

    class Program
    {
        static AutoResetEvent _lock = new AutoResetEvent(true);
        static int num = 0;
        private static void Increase()
        {
            for (int i = 0; i < 100000; i++)
            {
                _lock.WaitOne();
                num++;
                _lock.Set();
            }
        }
        private static void Decrease()
        {
            for (int i = 0; i < 100000; i++)
            {
                _lock.WaitOne();
                num--;
                _lock.Set();
            }
        }
        static void Main(string[] args)
        {
            Thread t1 = new Thread(Increase);
            Thread t2 = new Thread(Decrease);
            t1.Start();
            t2.Start();
            t1.Join();
            t2.Join();
            Console.WriteLine(num);
        }

    }

다른 스레드가 점령중인 상태에서 또 다른 스레드가 WaitOne 메서드에 진입한다면 WaitOne 에서 이벤트가 발생할 때까지 대기상태로 전환된다.

 

위와같이 대리자를 하나 만들어 사용하는 느낌

ReaderWriterLock

ReaderWriterLockSlim 클래스는 값을 단순히 읽기만 하는 작업에서 lock을 사용한다면 비효율적이기 때문에 만약 누군가 값을 쓰고 있지 않다면 자유롭게 읽을 수 있도록 하는 lock이다. 쓰는 작업보다 읽는 작업이 더 많을 경우 사용된다.

    class Program
    {
        static ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
        static int num = 0;
        private static void Increase()
        {
            for (int i = 0; i < 1000; i++)
            {
                _lock.EnterWriteLock();
                num++;
                _lock.ExitWriteLock();
            }
        }
        private static void Decrease()
        {
            for (int i = 0; i < 1000; i++)
            {
                _lock.EnterWriteLock();
                num--;
                _lock.ExitWriteLock();
            }
        }
        private static void ReadVal()
        {
            for (int i = 0; i < 1000; i++)
            {
                _lock.EnterReadLock();
                Console.WriteLine(num);
                Thread.Sleep(100);
                _lock.ExitReadLock();
            }
        }
        static void Main(string[] args)
        {
            Thread t1 = new Thread(Increase);
            Thread t2 = new Thread(Decrease);
            Thread t3 = new Thread(ReadVal);
            t1.Start();
            t3.Start();
            t2.Start();
            t1.Join();
            t2.Join();
            t3.Join();
            Console.WriteLine(num);
        }
    }

위 코드에서 누군가 WriteLock을 잡은 상태에서는 EnterReadLock과 EnterWriteLock에 접근한 다른 스레드 모두 대기 상태가 된다. 위의 코드는 ReadLock이 하나밖에 없기 때문에 일반 락과 다르지 않지만 ReadLock을 해주는 다른 스레드가 존재했다면 ReadLock 끼리는 서로 신경을 쓰지 않고 원할 때 접근 할 수 있다.

멀티스레드를 마치며 

3번에 걸쳐 lock을 구현하는 다양한 방법들과 멀티스레드 프로그래밍에서 꼭 알아야 하는 지식들에 대하여 공부를 하였다. 이 간단한 내용들을 2달에 걸쳐 끝내게 되었는데 방학 게임잼, 소켓 프로그래밍이랑 데이터베이스, 수학 공모전 프로젝트가 겹쳐서 8월달에 끝내려고 했던 나의 계획이 틀어져버렸다.. 

멀티스레드 프로그래밍 정리를 끝냈으니 다음엔 이번에 만들고 있는 소켓 프로그래밍 + 데이터베이스를 활용한 채팅 서버를 설명하겠다

전에 일어났던 레이스 컨디션을 해결하기 위한 lock을 구현하는 여러가지 방법들에 대하여 알아보겠다.

 

Monitor

 Monitor 클래스는 여러 스레드가 접근하지 못하는 영역인 임계 영역(Critical Section)을 만들어주는 역할이다. Monitor.Enter(obj) 으로 임계 영역으로 들어가고 Monitor.Exit(obj)로 임계 영역을 탈출한다. 앞에서 했던 Interlocked는 더이상 간단해질수 없는 원자 단위의 계산으로 레이스 컨디션을 해결했다면 임계 영역을 생성하는 방법은 아예 코드 자체에 다른 스레드를 접근하지 못하게 하여 레이스 컨디션을 해결한다.

 

using System;
using System.Threading;

namespace ThreadStudy
{
    class Program
    {
        static int num = 0;
        static object lock1 = new object();
        private static void Increase()
        {
            for (int i = 0; i < 100000; i++)
            {
                Monitor.Enter(lock1);
                try
                {
                    num++;
                }
                finally
                {
                    Monitor.Exit(lock1);
                }
            }
        }
        private static void Decrease()
        {
            for (int i = 0; i < 100000; i++)
            {
                Monitor.Enter(lock1);
                try
                {
                    num--;
                }
                finally
                {
                    Monitor.Exit(lock1);
                }
            }
        }
        static void Main(string[] args)
        {
            Thread t1 = new Thread(Increase);
            Thread t2 = new Thread(Decrease);
            t1.Start();
            t2.Start();
            t1.Join();
            t2.Join();
            Console.WriteLine(num);
        }

    }
}

위 코드에서 한가지 의문점이 들것이다. Monitor 클래스를 사용할때 try, catch 문을 사용하지 않고

Monitor.Enter(lock1);
num++;
Monitor.Exit(lock1);

이런식으로 하면 되는거 아닌가?

 

 위와 같이 간단한 작업만을 해줄때는 상관이 없지만 임계영역 안에 굉장한 복잡한 코드들이 있다고 가정을 했을때 임계영역 안에서 오류가 발생하여 점유중인 스레드가 강제로 종료된다면 어떻게 될까.Monitor.Exit(obj)를 실행시키지 못하고 스레드가 종료되어 다른 스레드들이 임계영역 안으로 접근하지 못하게 되어 무한정으로 대기하게 될것이다. 이렇게 무한정으로 대기하게 되는 상태를 교착상태(Deadlock) 이라고 하며 멀티스레딩, 병렬 프로그래밍에서 흔히 발생하는 문제중 하나다.

 그럼 try, catch 문을 사용하는 이유에 대해 감이 잡혔을 것이다. 임계영역 안에서 오류가 나더라도 finally 안에 있는 코드들을 실행시키고 종료되기 때문에 잠금을 풀어주어 다른 스레드들이 접근할수 있게 만들어 준다. 그런데 잠금을 설정 할때마다 try, catch 문을 사용하여 위 코드와 같이 나타낸다면 코드가 복잡해지고 가독성을 떨어트린다. 그래서 보통 정밀하게 조정하는 경우가 아니라면 더 간단한 lock 키워드를 사용한다.

Lock

lock 키워드는 Monitor 클래스와 같은 기능을 한다. 위에서 말했듯이 더 정밀한 작업을 하지 않는 이상 더 간결하고 가독성이 좋은 lock 키워드를 사용한다.

using System;
using System.Threading;

namespace ThreadStudy
{
    class Program
    {
        static int num = 0;
        static object lock1 = new object();
        private static void Increase()
        {
            for (int i = 0; i < 100000; i++)
            {
                lock (lock1)
                {
                    num++;
                }
            }
        }
        private static void Decrease()
        {
            for (int i = 0; i < 100000; i++)
            {
                lock (lock1)
                {
                    num--;
                }
            }
        }
        static void Main(string[] args)
        {
            Thread t1 = new Thread(Increase);
            Thread t2 = new Thread(Decrease);
            t1.Start();
            t2.Start();
            t1.Join();
            t2.Join();
            Console.WriteLine(num);
        }

    }
}

 위에서 봤던 Monitor 클래스와는 다르게 Exit를 해줄필요 없이 lock 키워드를 벗어나는 순간 알아서 잠금 해제가 된다.

SpinLock

SpinLock은 잠금이 풀릴때까지 임계 영역에 무한정 접근하려 시도하는 방법이다. lock 키워드와의 차이점은 lock 키워드는 잠금이 풀릴때까지 스레드를 Sleep 상태로 전환하여 컨텍스트 스위칭을 발생시켜 cpu의 사용량을 최소화 시킨다. SpinLock은 스레드를 Sleep 상태로 전환시키지 않고 계속 일을 시키기 때문에 짧은 시간 내에 잠금이 해제되는 경우 컨텍스트 스위칭이 일어나지 않아 성능을 향상시킬 수 있다.

    class SpinLock
    {
        volatile int _locked = 0;
        public void Acquire()
        {
            while (true)
            {
                if (_locked == 0)
                {
                    _locked = 1;
                    break;
                }
            }
        }
        public void Release()
        {
            _locked = 0;
        }
    }

 앞에서 말했던 대로라면 SpinLock을 이런 식으로 구현할 것이라고 생각할 것이다. 누가 점유하지 않을때(_locked == 0) _locked에 접근하여 1로 만들어 준 후 잠금을 풀때(Release) _locked를 0으로 해줌으로써 다른 스레드들이 접근을 할 수 있게 하고 무한반복문을 사용하여 _locked가 0이 될때까지 접근을 해주니까 말이다.

 하지만 위와같이 SpinLock을 구현하면 안된다. 전 게시물에서 말했듯이 점유되어 있는지 확인하는 과정과 점유를 하는 과정이 여러 단계로 이루어져 있기 때문에 원자성이 지켜지지 않았다.

 if(_locked==0) 에 여러개의 스레드가 동시에 도달하게 된다면 여러개의 스레드가 임계 영역을 침범하여 같은 값을 건드릴 수 있게 된다.

 이것을 해결하기 위해 Interlocked.CompareExchange() 를 사용해야 한다.

    class SpinLock
    {
        volatile int _locked = 0;
        public void Acquire()
        {
            while (true)
            {
                int original = Interlocked.CompareExchange(ref _locked, 1, 0);
                if (original == 0) break;
            }
        }
        public void Release()
        {
            _locked = 0;
        }
    }

_locked 가 0인지 확인하고 0이라면 _locked를 1로 바꿔준다. 그리고 Interlocked.CompareExchange() 는 바뀌기 전 원래의 _locked 값을 반환한다. 이걸 이용하여 만약 바뀌기 전이 0이라면 (점유되어있지 않았다면) while 문을 빠져나간다.

 

위의 SpinLock은 우리가 직접 구현해본 것이고 .net에서 지원하는 SpinLock 객체는 점유와 해제를 할때 boolean 변수를 받아와 점유를 성공 했는지 여부를 받아와 성공 했다면 해제를 할 수 있다.

        private static void Increase()
        {
            bool lockTaken = false;
            for (int i = 0; i < 100000; i++)
            {
                try
                {
                    _lock.Enter(ref lockTaken);
                    num++;
                }
                finally
                {
                    if(lockTaken)
                        _lock.Exit();
                }
            }
        }

 

오늘은 lock을 구현하는 방법들에 대하여 알아보았다. 다음에도 AutoResetEvent와 ReaderWriterLock을 사용한 lock 구현에 대해 알아보겠다.

 게임 서버를 만들기 위한 첫 발판으로 멀티스레딩 프로그래밍을 공부하였다. 이번 게시물에서는 스레드, 컨텍스트 스위치,  레이스 컨디션에 대해 설명하겠다.

 

Thread

 스레드는 프로세스에 존재하며 하나의 프로세스 내에서 실행되는 작업 단위이다. 보통 하나의 프로세스에 하나의 스레드가 존재하고 이것을 싱글스레드 환경이라고 부른다. 하나의 프로세스 안에 여러개의 스레드가 있는 경우는 멀티스레딩 환경이다.

위 그림에서처럼 스레드는 각자의 스텍메모리을 가지지만 프로세스 내의 Heap, Static, Code 메모리를 각각의 스레드가 사용할 수 있으므로 스레드 간의 자원공유가 빠르다.

 

우리가 C#에서 스레드를 사용할수 있는 방법은 Thread, ThreadPool, Task 키워드가 있다. 먼저 Thread 키워드를 알아보겠다.

Thread 키워드는 직접 스레드 하나를 생성한다.

namespace ThreadStudy
{
    class Program
    {
        private static void Thread_1()
        {
            for(int i = 0; i < 10000; i++)
                Console.WriteLine(i);
        }
        static void Main(string[] args)
        {
            Thread t1 = new Thread(Thread_1);
            t1.Start();
            t1.IsBackground = true;
            t1.Join();
        }

    }
}

 스레드는 new Thread(함수 이름) 형식으로 생성을 한다. Start() 매서드로 스레드를 실행 하고 Join() 매서드로 스레드가 끝날 때까지 기다릴 수 있다. 종료는 Abort()매서드로 할 수 있다. 그런데 C# 매뉴얼을 보면 Abort() 매서드를 주의해서 사용해야 한다고 나와있다.

 그러한 이유는 Abort() 매서드를 사용한다고 스레드가 즉시 종료되지 않는다. 또한 스레드가 한 자원을 점유한 상태에서 Abort() 매서드를 호출하여 종료되어 버린 경우 점유를 해제하지 못한 채 종료되기 때문에 다른 스레드들이 그 자원에 영원히 접근하지 못하게 되는 대참사가 발생할 수 있다.

 IsBackground 는 스레드가 백그라운드에서 실행될 것인지 포어그라운드에서 실행될 것인지 결정하는 것이다. 기본적으로 Thread 매서드로 만들어진 스레드는 포어그라운드 상태로 메인 스레드가 종료되더라도 만들어진 스레드가 다 실행되고 난 뒤 프로그램이 종료된다. 하지만 백그라운드에서 재생된다면 메인 스레드가 종료되고 만들어진 스레드가 동시에 종료된다.

Join() 매서드는 스레드가 종료될때까지 기다리는 매서드이다.

 

namespace ThreadStudy
{
    class Program
    {
        private static void Thread_1(object obj)
        {
        	Console.WriteLine("Hello World!");
        }
        static void Main(string[] args)
        {
            ThreadPool.QueueUserWorkItem(Thread_1);
        }

    }
}

 

 스레드풀은 이미 만들어진 스레드를 가져와서 사용하는 것이다. 예를 들어 10개의 스레드가 스레드 풀 안에 있을때 11개의 스레드를 동작시킨다면 10개가 먼저 실행된 후 10개중 하나의 스레드의 작업이 종료되면 그 스레드가 나머지 1개의 일을 하는식으로 동작한다.

위의 예처럼 하나의 스레드가 빨리 끝나는 작업을 하고 있다면 스레드풀이 굉장이 효율적으로 동작하지만 하나의 스레드가 오래걸리는 작업을 하고 있다면 실행되어야 하는 스레드가 스레드가 부족하여 실행되지 못하는 상황이 발생할 수 있다.

Context Switch

 컨텍스트 스위치란 스레드에 할당되어있는 cpu가 짧은 시간 내에 여러 스레드를 옮겨다니면서 마치 동시에 작업하는 것처럼 보이게 하는것을 말한다. 컨텍스트 스위치가 일어날때 cpu가 다른 스레드로 옮겨가는 과정에서 실행중이던 스레드의 정보를 저장하고 기억장치에 접근하여 실행할 스레드의 정보를 다시 가져오게 된다. 이 과정이 무겁기 때문에 스레드를 많이 사용하다보면 싱글스레드 프로그래밍보다 더 성능이 안좋아질 수 있다. 

Race Condition, Interlocked

using System;
using System.Threading;

namespace ThreadStudy
{
    class Program
    {
        public static int num = 0;
        private static void Increase()
        {
            for (int i = 0; i < 100000; i++)
            {
                num++;
            }
        }
        private static void Decrease()
        {
            for (int i = 0; i < 100000; i++)
            {
                num--;
            }
        }
        static void Main(string[] args)
        {
            Thread t1 = new Thread(Increase);
            Thread t2 = new Thread(Decrease);
            t1.Start();
            t2.Start();
            t1.Join();
            t2.Join();
            Console.WriteLine(num);
        }

    }
}

 위와 같은 예제를 실행하였을때 나와야 할 값을 생각해보자. 당연히 0이라고 생각할 것이다. 하지만 직접 실행시켜보면 이상한 값이 나온다. 그 이유는 레이스 컨디션이 일어나기 때문이다. num++을 어셈블리 코드로 보면

int temp = num;

temp += 1;

num = temp;

이렇게 세단계의 구조로 나뉘어져 있다. num--도 마찬가지다. 그러면 코드가 실행되는 순서가 

int temp = num;

temp += 1;

 

int temp2 = num;

temp2 -= 1;

 

num = temp;

num = temp2;

 

이런 순서로 실행된다면 num 에는 0이 아닌 -1이라는 값이 들어가게 될것이다.

 

 앞의 예제처럼 두가지의 스레드가 하나의 값을 변화시킬때 서로에게 영향을 줄 수 있는 상태를 레이스 컨디션이라고 한다.

Interlocked 키워드를 활용한다면 3단계로 이루어진 더하기와 빼기를 하나의 과정으로 처리를 하여 레이스 컨디션을 해결할 수 있다.

using System;
using System.Threading;

namespace ThreadStudy
{
    class Program
    {
        static int num = 0;
        static object lock1 = new object();
        private static void Increase()
        {
            for (int i = 0; i < 100000; i++)
            {
                Interlocked.Increment(ref num);
            }
        }
        private static void Decrease()
        {
            for (int i = 0; i < 100000; i++)
            {
                Interlocked.Decrement(ref num);
            }
        }
        static void Main(string[] args)
        {
            Thread t1 = new Thread(Increase);
            Thread t2 = new Thread(Decrease);
            t1.Start();
            t2.Start();
            t1.Join();
            t2.Join();
            Console.WriteLine(num);
        }

    }
}

위와같이 Interlocked.Increment 와 Interlocked.Decrement 를 사용하여 1을 더하고 빼는 과정을 하나의 과정으로 처리를 하여 서로 중간 과정에 값이 바뀌지 않도록 만들어 줄 수 있다.

 Interlocked 키워드로 여러 과정의 일을 하나로 처리해주는 것을 원자성 이라고 한다. 더이상 쪼갤 수 없는 원자처럼 어떤 동작을 쪼갤 수 없는 최소 단위로 행동하는 것이다. 

 

오늘은 스레드와 레이스 컨디션, 컨텍스트 스위치에 대하여 알아보았다. 멀티스레드에 대한 기본적인 이론들과 락 구현을 해보았으니 다음은 소켓 프로그래밍을 설명하겠다.

이번 1학년 1학기 개인프로젝트 'Cooking Mania'는 제한 시간 안에 손님들에게 음식을 가져다주어 돈을 벌고 빚을 천천히 갚아나가는 자영업자들의 힘든 생활을 담은 게임입니다.

 

게임 설명

기획 의도

처음엔 고난이도의 타임어택 게임을 만드려는 의도로 게임을 기획하였지만 게임이 단순 반복이 되어버리기 때문에 플레이어들이 지루하게 느낄수도 있겠다는 생각이 들었습니다. 그래서 빚을지고 사업을 시작한 자영업자라는 컨셉을 잡아서 빚을 천천히 갚아나가고 갚을때마다 손님이 늘어나고 돈을 더 많이 벌 수 있는 기믹을 추가하였습니다. 또한 난이도를 줄임에 따라 플레이타임이 늘어났기 때문에 이속증가, 요리시간 감소, 요리도구 추가 기능을 추가하였습니다.

플레이 방법

기본 조작은 WASD 이고 상호작용키는 Space 입니다. Esc 키를 누르면 일시정지 창을 열수 있고 일시정치장에서 음량을 조절할수 있습니다.

메인화면

메인화면에선 전체음량과 배경음악 음량을 조절할수 있고 처음 할땐 모를 수 있는 정보들을 알려주는 설명서를 볼 수 있습니다.

손님에게 음식을 건네는 장면

냉장고에서 음식을 꺼내 손님에게 전해주어 콤보를 올리고 돈을 벌 수 있습니다. 위에서 볼 수 있듯이 타이머가 있고 타이머가 다 지나기 전에 왼쪽 위에있는 빚을 NPC에게 전해주지 않으면 게임 오버가 됩니다. 빚을 갚으면 다음 스테이지로 넘어갈 수 있고 넘어가면 빚이 증가되고, 타이머의 시간이 증가됩니다.

일시정지
음식을 굽는 장면
레시피
햄버거 만드는 장면

도마에 상호작용하여 UI 열고 햄버거를 만들기 위해선 먼저 위에있는 포스트잇에 상호작용하여 레시피를 확인하고 레시피 순서대로 도마에 배치하여 햄버거를 만들수 있습니다.

상점

상점에서 물건을 구매할때 한번 누르면 3개씩 증가되며 쉬프트키를 누른채로 클릭시 9개씩 늘어납니다. 오른쪽 파란 영역에 있는 아이콘 클릭시 3개씩 줄어들며 역시 쉬프트키를 누른채로 클릭시 9개씩 감소합니다.

상점에서 페이지를 바꾸면 이동속도 증가, 조리시간 감소, 후라이팬 잠금해제를 할 수 있는 창이 나타납니다. 이동속도와 조리시간은 최대 5레벨 까지 올리기가 가능하고 레벨이 오를수록 가격이 점차 증가합니다.

내가 생각한 

 

추후 콘텐츠

현재 음식이 햄버거만 존재하고 조리도구도 후라이팬만 있는것이 아닌 다양한 조리도구를 추가하여 더 많음 음식들 ex)피자, 핫도그 등 을 추가하여 더 다양한 게임플레이가 가능하도록 만들 것입니다.

다양한 조리도구와 요리가 존재하는 동방야작식당
일수가 증가할수록 더 많은 요리와 도구들이 추가됨

그리고 게임을 클리어하면 다음맵으로 넘어가 맵이 더 넓어지고 테이블 수를 늘리는 등의 새로운 컨텐츠들을 추가할 것입니다.

배운내용 적용

블렌드 트리

유니티의 블렌드 트리(Blend Tree)는 애니메이션 상태를 부드럽게 전환하고 다양한 애니메이션을 조합하여 자연스러운 애니메이션을 만드는 데 사용됩니다. 블렌드 트리는 주로 캐릭터 애니메이션에서 사용되며, 움직임의 속도나 방향에 따라 애니메이션을 자연스럽게 전환하는 데 유용합니다.

방향에 따라 애니메이션을 설정해주는 탑다운 블렌드 트리

적용

탑다운 게임이기 때문에 자연스러운 움직임을 위해선 멈췄을때의 방향을 알고 그때의 애니메이션을 따로 설정해줘야 합니다. 그래서 상하좌우의 Idle, Move 블렌드트리를 따로 만들어주어서 Idle 블렌드 트리에서는 멈추기 전 마지막 운동 방향에 따라 상하좌우에 맞는 애니메이션을 설정해주고 Move 블렌드 트리에서는 움직일때 방향에 따라 상하좌우에 맞는 애니메이션을 설정해 줍니다.

스크립터블 오브젝트(SO)

SO는 데이터를 저장하는 컨테이너로 게임 데이터를 관리하고 공유하는데 사용됩니다. SO는 다음과 같은 특징을 가지고 있습니다.

SO는 CreateAssetMenu 속성을 사용하여 유니티 에디터에서 쉽게 생성하고 편집할 수 있습니다. 또한 에디터에서 직접 데이터 파일을 생성하여 속성을 설정할 수 있고 메모리에 효율적입니다. 게임오브젝트와 다른점은 게임 게임오브젝트는 씬에 존재할때 메모리에 상주하는 반면 SO는 필요할 때 로드되고, 사용하지 않을 때 메모리를 차지하지 않습니다.

다음으로 여러 씬 간에 데이터 공유가 용이합니다. SO는 에셋파일에 생성할 수 있기때문에 값을 바꾸고 다른 씬에서 같은 SO를 사용하면 바뀐 값을 사용할 수 있습니다. 여러 씬에서 공통으로 사용하는 설정이나 데이터(예: 게임 설정, 아이템 데이터 등)를 SO로 만들어 사용하면 한 곳에서 데이터를 관리할 수 있습니다.

적용

이번 프로젝트에서 SO는 재료와 음식의 요리 시간, 가격, 스프라이트 등의 이미지를 저장하는데 사용하였고 음량 조절을 메인 화면과 게임화면 모두 같은 음량으로 바꾸는데에 사용하였습니다.

왼쪽은 음식 데이터 저장, 오른쪽은 음량 데이터 저장

싱글톤

싱글톤이란 하나의 인스턴스를 어디에서든 접근 가능하게 하여 코드의 가독성과 일관성을 높일 수 있게 만드는 방법을 말합니다. 그래서 게임 메니저나 플레이어같이 한 씬에 하나의 스크립트만 존재할 경우에 사용해야 합니다. 만약 한 씬에 2개 이상이 존재할시 오류가 날수 있기때문에 아래와 같이 중복된 인스턴스를 제거해주어야 합니다.

GetComponent를 안해줘도 된다는 편리함이 있고 접근이 쉽게 가능하다는 장점이 있으니 마구잡이로 사용하다보면 코드가 꼬일 확률이 높아진다는 단점이 있습니다.

적용

빚을 관리하는 스크립트에 싱글톤을 사용하여 빚의 금액, 타이머를 다른 스크립트에서 사용할수 있도록 해주었습니다.

싱글톤을 사용하지 않았더라면 DebtManager에서 따로 SetStage 매서드를 사용하거나 GetComponent를 해주어야하는 번거로움이 있었을 것입니다. DebtManager는 씬에 하나 존재하는데 굳이 GetComponent를 해줄 필요가 없겠죠?

게임 후기

 보통 요리게임 하면 타임어택, 경쟁, 힐링게임 세개의 특징이 부각되는 게임들이 많았다고 생각합니다. 하지만 Cooking Mania는 시간 제한과 빚 시스템으로 타임어택 요소가 존재하지만 게임의 전체적인 분위기와 난이도를 조정함으로써 힐링게임의 요소도 어느정도 포함되어 있다는 차별성이 있다고 생각합니다. 또한 제가 게임을 플레이해보면서 느낀 재미는 게임 내에서 타이머와 빚 이라는 제약 요소로 빠르게 음식을 만들어 빠르게 서빙을 해야하는 스릴이었습니다.

+ Recent posts