본인은 컴퓨터공학과를 전공했는데 정보처리기사는 컴퓨터공학과 관련 국가기술자격증 중 가장 대표적인 자격증 중 하나이다. 또한 정말 멀게만 느껴졌던 졸업반이 되고야 만 것이다.. 그 말은 즉슨 정보처리기사 응시 자격요건을 달성한 것! 내년에는 세워둔 목표가 이미 있기 때문에 취준을 위한 자격증을 준비 할 시간이 많이 없을 것 같아서 필요한 자격증들을 올해 전부 따보자! 하고 다짐했다. 그래서 바로 도전을 시작하게 된 것이다.
자격증 취득 소요 기간
플래너에 기록된 내용을 바탕으로 필기 9일 + 실기 28일 = 총 37일이 소요됐다.
실기는 방학 동안 준비하여 하루에 8시간 넘게 공부에 투자할 수 있었는데 필기는 학기 중에 준비해서 졸작 + 과제 때문에 하루 동안의 순 공부 시간은 길지 않다. 그렇지만 전공자분들은 정말 빡세게 준비한다면 필기는 일주일 안으로 충분히 합격 가능할 것 같다.
실기도 벼락치기 하시는 분들이 상당히 많던데 요즘 실기 출제 내용들을 보니 프로그래밍적 지식이 충분한 전공자라면 벼락치기가 가능할 것 같긴 하다. 프로그래밍 문제 출제 비율이 상당히 높다. 그리고 개념과 관련된 문제는 자주 나오는 유형들이 정해져 있어서 그 부분들만 빡세게 외우고 시험을 쳐도 문제는 없을 것 같다. 그렇지만 본인은 벼락치기 스타일이 아니기도 하고 방학 동안 준비하기도 해서 시간적인 여유도 충분했다. 그래서 약 4주동안 열심히 실기를 준비했던 것 같다.
그리고 수제비 교재를 구입하면 수제비 카페를 가입하는 것을 추천드린다. 교재를 보다 이해가 되지 않는 내용이나 풀지 못하는 문제들에 대한 질문도 할 수 있고 사람들과 함께 스터디도 할 수 있고 자기 전 데일리 문제를 통해 공부했던 내용을 상기시킬 수도 있다. 데일리 문제를 정말 많이 이용했다. 매일 자기 전에 올라온 데일리 문제를 무조건 풀고 잤던 기억이 난다. 그리고 기출문제도 확인해 볼 수 있고 실기 시험이 끝난 경우 사람들과 함께 집단지성으로 가채점이 가능하다.
수제비 교재를 구매하지 않았더라도 정처기를 준비하시는 분들이 가입하기 정말 좋은 카페다. 정처기 실기를 준비하는 약 4주 동안 매일 해당 카페에 출석했던 것 같다. 그만큼 정말 유용했다.
우선 해당 교재를 구매한 뒤 처음으로 1회독을 할 때는 교재에 있는 모든 내용들을 꼼꼼히 읽어본 다음 기출문제와 예상문제를 풀고 오답노트를 정리했다. 그냥 무작정 교재에 있는 모든 내용을 읽어보는 것에 초점을 맞췄다. '미리보기', '학습 Point', '잠깐! 알고가기', '두음쌤 한마디' 등 옆에 있던 조그마한 글씨도 전부 읽어줬다. 그리고 단원 끝날때마다 있는 실기시험 합격 후기도 내 미래가 되면 좋겠다고 생각하며 전부 읽었던 기억이 난다. 포인터 부분을 처음 공부할 때 정말 어려웠던 기억이 난다. 그래서 하루 종일 포인터 내용을 공부하고 이해한 뒤 이를 노트에 정리해뒀더니 그 후로는 어렵지 않았던 것 같다.
2회독할 때는똑같이 수제비 교재를 전부 읽되 핵심 내용들을 노트에 무작정써 내려갔다. 교재 읽기 + 노트 필기 + 내용 말하기를 동시에 병행했더니 생각보다 머리에 잘 남아서 이런 방식으로 2회독을 했던 것 같다. 모든 내용을 노트에 적는 방법이 누가 보면 무식하다고 생각할 수 있지만 본인에게는 정말 효과가 있었던 것 같다.
3회독 할 때는 수제비 교재 내용을 통해 직접 내용 요약본을 만들었다.
수제비 교재 내용을 3회독한 뒤 유튜브에서 다양한 분석 강의를 참고하여 2024년 1회 기출문제를 분석했다.
2024년 기출문제 분석 후 2020년 ~ 2023년 기출 문제도 풀고 오답까지 정리해줬다.
본인은 서브넷 관련 문제가 너무 어려웠는데 유튜브를 통해 개념을 완벽하게 이해할 수 있었다. 유튜브 강의 내용을 바탕으로 서브넷 개념을 정리한 뒤 나중에 다시 풀어볼 수 있도록 관련 문제들까지 정리해줬다. 다양한 유튜브 채널이 있는데 본인은 "흥달쌤"을 진짜 추천한다. 설명을 너무 잘하셔서 이해가 쏙쏙 된다.
그런 다음 수제비 FINAL 실전 모의고사를 풀었는데 이 책 문제들이 기출문제 난이도에 비해 상당히 높다.. 수제비 카페 운영진분들도 해당 책 난이도가 기출문제보다 더 어렵다고 하셨다. 모의고사 약 10회분 정도를 풀다가 이렇게 나올 것 같지 않아서 그냥 그만뒀다. 해당 모의고사를 풀던 시점에는 시험이 얼마 남지도 않았고 해서 이걸 푸는데 하루를 투자할 바엔 직접 정리한 내용 요약본 및 기출 분석을 한 번 더 보는 게 더 좋을 것 같다는 생각이 들어서기도 했다.
실기 시험을 치고 나와 집으로 돌아가는 버스를 기다리면서 수제비 카페를 통해 사람들과 함께 가채점 했을 당시에 3문제는 확실히 틀렸고 1문제는 본인이 적은 답이 기억나지 않아서 그냥 틀렸다고 간주하고 80점을 예상했었는데 결과는 85점이 나왔다. 적은 답이 기억나지 않았던 포인터 문제를 맞췄나보다. 포인터 공부에만 하루를 투자했었는데 열심히 공부한 보람이 있다.
ISTQB 자격증은 비영리 국제 소프트웨어(SW) 테스팅 전문가 네트워크인 국제 SW 테스팅자격위원회에서 주관하는 국제 자격증 프로그램이다. 국제 자격증이므로 특정 기업이나 국가에 제한되지 않고 한번 취득으로 전세계 어느 국가에서나 통용되며 유럽과 아시아를 중심으로, 세계 130여개 이상의 국가가 가입되어 활발히 활동하고 있다.
ISTQB는 CTFL, CTAL, CTEL로 나뉜다. CTFL은 실무 경험이 없어도 취득 가능하다. CTAL은 CTFL 자격증 보유, 4년제 학위, 2년 이상의 관련 분야 경력이 필요하다. CTEL은 최소 8년 이상의 관련 분야 경력이 필요하다.
응시료는 198,000원(학생 할인시 20% 할인)이며 40문항 중 26문항 이상(백분율 65%이상) 맞출 경우 합격이다. ISTQB-CTFL에 대한 자세한 설명은 아래 페이지를 참고하면 된다.
ISTQB는 올해 초부터 눈여겨봤던 자격증이다. 우선 국제 자격증이라는 점과 한번 취득하면 영구적으로 유효하다는 점이 굉장히 마음에 들었다. 또 다른 이유로는 자격증 내용 대부분이 3학년 전공 핵심 과목이었던 '소프트웨어 공학'에서 다룬 내용들이라 전공 지식을 잊어버리기 전에 취득하면 좋을 것 같다고 생각했다.
QA 자격증으로도 많이 알려져있는데 QA 뿐만이 아니라 기획, 개발 준비에도 어느정도 도움이 될 것 같다고 생각하여 방학 중 3주정도를 해당 자격증을 공부에 투자하였다.
그리고 ISTQB CT-GaMe이 올해 8월부터 국내에서 국제자격시험이 시행되는데 ISTQB CT-GaMe 응시 자격 조건이 ISTQB-CTFL을 보유하고 있어야 한다. 이 점도 취득 이유 중 하나이기도 하다.
친구들과 글램핑을 다녀온 뒤 역으로 돌아가는 택시 안에서 스마트폰으로 시험을 접수했는데 서버가 터져서 몇 번이나 재접속을 했는지 모르겠다.. 택시 안에서 양옆에 앉은 친구들이 도와줘서 겨우 접수에 성공했던 기억이 난다. 시험 인원이 108명까지 받는데 30분도 안 되서 조기 마감된 걸 보고 진짜 충격받았다.
느긋하게 시험 접수할 생각 마시고 꼭 접수 당일, 접수 신청 오픈 시간에 맞춰서 신청하는걸 추천한다. 열심히 자격증 공부 했는데 접수 못하면 공부했던 지식도 무용지물이 되니까..
현재 4년제 대학에 재학중이여서 학생 할인을 받기 위해 정부 24에서 근처 행정복지센터에 재학 증명서를 요청한 뒤 해당 재학 증명서를 스캔하여 jpg 파일을 1:1 게시판에 제출하였다. 할인 금액 반영이 늦길래 고객센터에 전화하니 바로 반영해 주셨다. 학생 할인받기 전 금액이 198,000원인데 학생 할인 20%를 받아도 무려 158,400원.. 한 달 알바비를 거의 탕진..
그리고 또 중요한 점은 시험 장소가 "서울"이라는 사실이다. 나같이 지방에 사는 학생에게 너무 가혹하다 싶었지만 시험 응시비 + 왕복 기차비가 나에게 독기를 부여해줬다고 생각한다. 그리고 시험 치러 서울 가는 김에 서울 구경이나 하자~ 하고 긍정적으로 생각했다. 금액이부담돼서한 번에합격 못하면 어쩌지 하고 정말 걱정했는데 다행히한 번에합격하게 돼서기쁘다.
공부 방법
ISTQB 자격증은 "실러버스"와 "샘플문제 A~D"를 통해 준비할 수 있고 이 외에 잘 알려진 교재들은 "개알"과 "문배"이다.
시험 준비 전에 인터넷에서 ISTQB 합격 후기를 많이 찾아봤는데 많은 사람들이 "실러버스"와 "샘플문제 A~D"만 있어도 자격증 준비가 가능하다고 했다. 공부 방법은 사람마다 스타일이 다르기도 하고 성격상 본인이 직접 확인하지 않는 이상 믿을 수 없기에 "개알"과 "문배"도 전부 구입하였다.
결론부터 말하자면 "개알"과 "문배" 없이 "실러버스"와 "샘플문제 A~D"만으로도 충분히 공부가 가능하다고 생각한다.
"개알"은 "실러버스" 내용을 추가적으로 디테일하게 설명해주고 예시 또한 포함되어 있어 "실러버스" 내용을 이해하는데 도움이 되긴 한다. 근데 "실러버스"에 없는 내용도 많아서 오히려 머리가 더 복잡해지는 기분이 들었다. 그래서 "개알"은 자격증 준비하는 동안 쳐다도 안 봤다. 가끔 결정 테이블 테스팅이나 상태 전이 테스팅 예시를 확인하는 정도?
"문배"도 "실러버스"에 없는 내용이 정말 많다. 문제를 풀다가 이런 내용이 "실러버스"에 있었나? 싶은 문제가 상당히 많았다. 뒤에 모의고사가 1회분 있는데 이게 그나마 "샘플문제 A~D" 유형과 비슷했다. 물론 본인은 "문배" 문제를 전부 다 풀고 오답까지 하긴 했다. 그렇지만 "문배"를 사지 않고 "샘플문제 A~D"만 풀어도 충분하다고 생각한다. "샘플문제 A~D"를 N회독 해서 지겹다. 더 많은 문제를 풀고 싶다. 하시는 분들은 사도 나쁘지 않을듯? 그냥 참고하는 정도?
자격증 공부 방법은 우선 "용어"를 먼저 공부했다. KSTQB에서제공해 주는"표준 용어집"은알파벳순과가나다순뿐이어서유사하거나 관련된 용어들이 중구난방으로흩어져 있었다.그래서 직접 용어집을 만들어서 공부했다.ISTQB 자격증을 공부하다보면 용어가 정말 헷갈린다. 그래서 용어를 먼저 정리해주고 공부하는걸 추천한다. 직접 만든 용어집을 최소 3회독은 했던 것 같다.
그런 다음 "실러버스"를 공부했다. "실러버스"가 모든 문장이 따닥따닥 붙어있어서 내용이 한눈에 들어오지 않았다. 그래서 직접 내용 요약본을 만들어서 공부했다. 직접 만든 내용 요약본을 최소 5회독은 했던 것 같다.
직접 만든 용어집과 내용 요약본을 N 회독한 뒤 "샘플문제 A~D"를 풀었다. N 회 독하고 나니까 샘플 문제 전부 30분 내로 풀렸다. 점수도 전부 30점 이상은 나왔던 것 같다. 문제를 전부 풀고 나면 정답과 해설을 참고하여 오답 및 선지와 관련된 내용들을 전부 정리해 줬다."샘플문제 A~D"는 각각 1번씩만 풀었고 정리된 오답노트 및 선지와 관련된 내용들을 N 회독했던 것 같다.
문배도 풀긴 풀었는데 풀면 풀수록 자괴감이 들었다.. 개념을 충분히 봤다 생각하고 풀었는데 많이 틀리니까 내가 공부를 제대로 안 한 건가? 하고 의심하게 만드는 느낌? 진짜 "실러버스"에 없는 내용이 주구장창 나와서 당황스러웠다. 그래도 구매한 돈이 아까워서 전부 풀고 오답도 하긴 했다. 이건 N 회 독할 가치가 없다고 생각해서 하진 않았다.
"샘플문제 A~D"와 "문배"를 전부 풀고 오답까지 완료한 뒤 문제를 풀면서 "실러버스"에서 놓친 새로 알게된 내용들을 정리해 줬다. 이것도 N회독 했던 것 같다.
기차 안에서 볼 요약본도 전 날 카페에서 열심히 만들고 서울 가는 기차 안에서 해당 요약본으로 공부했다. 서울에 도착해서 시험 치는 곳 바로 앞에 별마당도서관이 있길래 구경하고 밥 먹고 하니까 시험 시간이 거의 다돼서시험 치는 곳 바로 앞에 있는 스타벅스로 향했다. ISTQB는 뭔가 같이 준비하는 사람들끼리 지식을 공유하는 카페? 같은 것도 없고 해서 뭔가 이 세상에서 나 혼자만 준비하는 것 같은 기분이 드는 자격증이었는데 스타벅스에 있는 사람 90%가 ISTQB를공부 중인 것을 보고 너무 반가웠다. 본인은 2024년 8월 29일 오후 4시 시험을 응시했다. 시험 후기를 말해보자면 불합격을 예상했다. 문제가 샘플문제 난이도랑 차원이 다르다고 생각했다. 나만 그렇게 느꼈나? 무튼 너무 어려워서 나중에 풀어야지 하고 넘긴 문제만 5~6문제가 넘어가고 문제도 말장난 천지에 시간도 너무 부족했다. 그래서 뭔가 확신을 가지고 푼 문제가 몇 문제 되지 않았다. 불합격을 예상하며 착잡한 마음으로 시험장을 나왔는데 회사에서 단체로 왔는지 여러 사람들이 모여서 본인들끼리 막 이야기를 나누는걸 슬쩍 들었는데 다들 시간이 부족했고 어려웠다고 하는 걸 듣고 나만 그런 게 아니였구나 싶었다. 약간 반포기 상태로 서울 구경이나 하다 가자~ 하고 시험 치고 나서 1박 2일 동안 친구랑 서울에서 야무지게 놀고 집으로 돌아왔는데 시험 결과 발표 3~4일 전에 ISTQB 합격, 불합격 꿈을 계속 꾸는 걸 보고 어쩌면 이 자격증에 정말 진심이구나 싶었다 ㅋㅋ 다행히한 번 만에합격해서 너무 행복하다! ISTQB CT-GaMe 자격증도 취득해서 합격 후기를 올리는 그날까지~ 화이팅!
친구가 2024년에 컴활이 개정된다고 해서 2024년이 지나기 전에 컴활 1급과 2급을 전부 따보자고 다짐했다. 컴활 2급 필기와 실기를 전부 1트 만에 합격해서 컴활 1급도 껌이겠네~ 싶어 컴활 1급도 바로 도전을 시작했다.
자격증 취득 소요 기간
todo mate에 기록된 내용을 바탕으로 필기 9일 + 실기 25일(12일+13일) = 총 34일이 소요됐다.
컴활 2급을 합격한 뒤 바로 준비하려고 했는데 중간시험 + 팀플 + 과제 때문에 11월 말에 필기를 준비 및 응시하고 기말시험 + 연말 행사에 치여 실기는 2024년이 되고 나서야 준비를 시작할 수 있었다.. 2024년 개정 전에 자격증을 취득하려 했으나 컴활 1급 실기는 2024년 개정 이후에 준비 및 응시를 한 셈이다.
실기는 2024년 1월에 12일정도 공부를 한 뒤 응시를 했는데 응시하고 나오자마자 바로 4일뒤 시험을 접수했다. 그만큼 너~무 어려웠다. 첫번째 시험때 부족했던 점들을 바탕으로 4일동안 준비하여 다시 응시하였으나 이 또한 불합격....
개강 전 방학 동안 따려고 했는데 실기 합격 여부가 2주나 소요되기도 했고 다른 공부 + 개강 준비 때문에 미루고 미루다가 공부했던 지식들이 아까워서 개강한 뒤 3월말에 정~말 빡세게 준비해서 4월 초에 3일 연속으로 연달아 시험을 접수했다.
3번째 시험은 불합격이였고 4번째, 5번째 시험은 합격이였다.
컴활 1급은 컴활 2급에 비해 난이도가 엄청나며 운 또한 상당히 따른다고 생각한다. 정말 완벽하게 준비했다고 생각해서 자신만만하게 시험을 하루만 접수하면 안 된다. (본인이 그랬다가 후회함) 무조건 3일 이상 연속으로 접수할 것을 추천 드리고 쉽게 나오길 기도하며 시험장에 들어가야한다. 그만큼 자리운이 상당히 따른다.. 허허.. 나도 알고 싶지 않았어요..
진짜 어떤 날은 이게 뭐지? 무슨 유형이세요? 처음 보는데요? 하면서 멘탈을 탈탈 털리게 만들었고 어떤 날은 너무 쉬워서 3번 이상 검토할 정도로 시간이 남았던 적이 있었다. 또 어떤 날은 엑셀이 너무 쉽게 출제됐으나 엑세스가 너무 어려웠고 또 어떤 날은 엑셀이 너무 쉬워서 의욕 상실 + 멘탈 공격 해놓고 엑세스는 너무 쉽게 나왔던 적도 있었다.
- 기존에는 Resources 폴더 산하에 Prefab, Sprites, Sound 등의 폴더를 생성하여 계층구조를 통해 에셋을 관리하였다.
~> Addressable을 통해 불필요한 에셋의 로드를 방지할 수 있다.
- [ Window ] - [ PackageManager ] 에서 Packages를 Unity Registry 로 바꾼 뒤 'Addressables' 검색 후 Install 한다.
~> 설치 후 [ Window ] - [ Asset Management ] - [ Groups ] - [ Create Addressables Settings ] 에서 초기 세팅을 한다.
~> [ New ] - [ Blank (no schema) ] 를 통해 새로운 그룹을 만들 수 있다. (그룹 단위로 묶어 관리 가능)
~> 변경된 사항이 있다면 [ Build ] - [ New Build ] - [ Default Build Script ] 를 해줘야 빌드시 오류가 발생하지 않는다.
~> 사용하고자 하는 에셋의 [ Inspector ] 창에서 [ Addressable ] 를 체크하여 추가할 수 있다.
~> 기본적으로 Address 값은 에셋의 경로로 설정 되지만 사용자가 임의로 수정할 수 있다. (Address 값을 통해 로드)
~> 에셋의 경로를 바꿔도 Path는 자동으로 수정된다.
- Addressable은 비동기 호출이다.
~> 기존에 사용하던 Resources.Load() 는 로드가 끝나야지만 다음 코드로 넘어간다. (동기 호출)
~> Addressable은 당장 실행되는 것이 아니므로 콜백 함수를 사용한다. (로딩이 완료될 경우 실행하도록)
~> Release를 해줘야 메모리에서 사라진다.
Addressable 사용 예제
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public class TestAddressable : MonoBehaviour
{
void Start()
{
AsyncOperationHandle<GameObject> obj = Addressables.LoadAssetAsync<GameObject>("KeyName");
obj.Completed += (val) =>
{
GameObject go = val.Result;
Debug.Log(go.name);
Addressables.Release(obj);
};
}
}
# 스테이지와 인벤토리
- 첨부파일의 AlicePang 프로젝트를 통해 찾고자 하는 기능이 어디에, 어떤식으로 구현되어 있는지 직접 찾고 확인해보기
- 가챠 시스템에서 아이템을 뽑을때 대부분 아이템이 바로 등장하는 것이 아닌 다양한 효과가 발생한 뒤 등장한다.
~> Animation도 상태를 통해 관리하는 것이 좋다.
상태를 통한 Animation 관리 예제
void OnClickTouchPanel()
{
Debug.Log("ClickTouchPanel");
switch(_animationSequence)
{
case AnimationSequence.StartGacha:
state.Complete -= MotionToWaitForOpenIdle;
WaitForOpenAnimation();
break;
case AnimationSequence.WaitForOpenIdle:
OpenAnimation();
break;
case AnimationSequence.Open:
state.Complete -= MotionToAfterOpen;
AfterOpenAnimation();
break;
case AnimationSequence.AfterOpenIdle:
WaitForShowCardAnimation();
break;
case AnimationSequence.WaitForShowCardIdle:
ShowCardAnimation();
break;
case AnimationSequence.ShowCard:
ShowCardAnimation();
break;
case AnimationSequence.EndGacha:
break;
}
}
# 전투 코드 분석
- 첨부파일의 AlicePang 프로젝트를 통해 찾고자 하는 기능이 어디에, 어떤식으로 구현되어 있는지 직접 찾고 확인해보기
- Polygon Collider 2D는 2D 물리 시스템과 상호작용하는 Collider 2D 컴포넌트이다.
~> 모양은 라인 세그먼트의 자유형 가장자리로, 스프라이트 모양이나 다른 모양에 맞게 조정할 수 있다.
- [ Server/Cloud Settings ] 의 [ App Id PUN ] 에 붙여넣는다.
[ Photon 기능 함수 ]
Photon 기능 함수
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using Photon.Pun; // MonoBehaviourPunCallbacks를 상속 받기 위해 추가
using Photon.Realtime; // MonoBehaviourPunCallbacks를 상속 받기 위해 추가
public class NetworkManager : MonoBehaviourPunCallbacks // MonoBehaviour이 아닌 MonoBehaviourPunCallbacks를 상속 받는다.
{
public Text StatusText;
public InputField roomInput, NickNameInput;
void Awake() => Screen.SetResolution(960, 540, false);
void Update() => StatusText.text = PhotonNetwork.NetworkClientState.ToString(); // PhotonNetwork.NetworkClientState.ToString()는 현재 상태를 문자열로 반환
public void Connect() => PhotonNetwork.ConnectUsingSettings(); // Photon Online Server에 접속
public override void OnConnectedToMaster() // Call back 함수
{
print("서버접속완료");
PhotonNetwork.LocalPlayer.NickName = NickNameInput.text;
}
public void Disconnect() => PhotonNetwork.Disconnect(); // Photon Online Server와의 연결 끊기
public override void OnDisconnected(DisconnectCause cause) => print("연결끊김"); // Call back 함수
public void JoinLobby() => PhotonNetwork.JoinLobby(); // 로비에 접속
public override void OnJoinedLobby() => print("로비접속완료"); // Call back 함수
public void CreateRoom() => PhotonNetwork.CreateRoom(roomInput.text, new RoomOptions { MaxPlayers = 2 }); // 방 생성 (방 이름, 최대 플레이어 수, 비공개 지정 가능)
public override void OnCreatedRoom() => print("방만들기완료"); // Call back 함수
public void JoinRoom() => PhotonNetwork.JoinRoom(roomInput.text); // 방 참가 (방 이름으로 입장 가능)
public override void OnJoinedRoom() => print("방참가완료"); // Call back 함수
public void JoinOrCreateRoom() => PhotonNetwork.JoinOrCreateRoom(roomInput.text, new RoomOptions { MaxPlayers = 2 }, null); // 방 참가, 방이 없으면 생성후 참가
public void JoinRandomRoom() => PhotonNetwork.JoinRandomRoom(); // 방 랜덤 참가
public void LeaveRoom() => PhotonNetwork.LeaveRoom(); // 방 떠나기
public override void OnCreateRoomFailed(short returnCode, string message) => print("방만들기실패"); // Call back 함수
public override void OnJoinRoomFailed(short returnCode, string message) => print("방참가실패"); // Call back 함수
public override void OnJoinRandomFailed(short returnCode, string message) => print("방랜덤참가실패"); // Call back 함수
// [ Script Component ] - [ 오른쪽 마우스 ] - [ 정보 ] 를 통해 실행 가능
[ContextMenu("정보")]
void Info()
{
if (PhotonNetwork.InRoom)
{
print("현재 방 이름 : " + PhotonNetwork.CurrentRoom.Name);
print("현재 방 인원수 : " + PhotonNetwork.CurrentRoom.PlayerCount);
print("현재 방 최대인원수 : " + PhotonNetwork.CurrentRoom.MaxPlayers);
string playerStr = "방에 있는 플레이어 목록 : ";
for (int i = 0; i < PhotonNetwork.PlayerList.Length; i++) playerStr += PhotonNetwork.PlayerList[i].NickName + ", ";
print(playerStr);
}
else
{
print("접속한 인원 수 : " + PhotonNetwork.CountOfPlayers);
print("방 개수 : " + PhotonNetwork.CountOfRooms);
print("모든 방에 있는 인원 수 : " + PhotonNetwork.CountOfPlayersInRooms);
print("로비에 있는지? : " + PhotonNetwork.InLobby);
print("연결됐는지? : " + PhotonNetwork.IsConnected);
}
}
}
[ RPC란? ]
- Player의 움직임을 동기화하기 위해서는 "Photon View"와 "Photon Transform View" Script를 Component로 추가해준다.
~> "Photon Transform View" Script Component 의 Synchronize Options 에서 무엇을 동기화할 것인지 선택할 수 있다.
(Position, Rotation, Scale 에 관한 동기화 선택이 가능하다.)
~> "Photon View" Script Component 의 Observed Components에 "Photon Transform View" Script Component 를
드래그 앤 드롭으로 연결한다. (이는 "Photon Transform View"Script Component 를 관찰하여 동기화 한다는 것)
~> 동기화는 "Photon View" Script Component 의 Controlled locally가 true인 경우에만 가능하다.
(Controlled locally는 PhotonView PV; PV.IsMine 으로 확인이 가능하다.)
- Position, Rotation, Scale 를 제외한 나머지는 어떻게 동기화 시킬까?
~> RPC 함수를 통해 동기화 시켜야 한다.
RPC 함수 선언 및 사용 예제
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
public class PlayerScript : MonoBehaviourPunCallbacks
{
public PhotonView PV;
public SpriteRenderer SR;
void Update()
{
if (PV.IsMine)
{
float axis = Input.GetAxisRaw("Horizontal");
transform.Translate(new Vector3(axis * Time.deltaTime * 7, 0, 0));
// RPC 함수 호출 (실행하고자 하는 함수 이름, 타겟, 인자)
// RPCTarget.All은 그 즉시 호출되어 사라지지만, AllBuffered는 재접속될때 호출된다.
if (axis != 0) PV.RPC("FlipXRPC", RpcTarget.AllBuffered, axis);
}
}
// RPC 함수 선언
[PunRPC]
void FlipXRPC(float axis)
{
SR.flipX = axis == -1;
}
}
~> "Photon Animator View" Script Component 의 Synchronize Layer Weights 와 Synchronize Parameters 선택지 중
"Disabled" 는 "사용 안 함", "Discrete" 는 "On/Off시 호출", "Continuous" 는 "수시로 호출" 에 해당한다.
~> "Photon View" Script Component 의 Observed Components에 "Photon AnimatorView"Script Component 를
드래그 앤 드롭으로 연결한다. (이는 "Photon AnimatorView"Script Component 를 관찰하여 동기화 한다는 것)
[ 변수 동기화 ]
- 변수를 동기화하기 위해서는 MonoBehaviourPunCallbacks 뿐만이 아닌 IPunObservable 도 상속 받는다.
~> IPunObservable 를 상속 받는 경우 OnPhotonSerializeView 인터페이스 구현이 필수다.
~> 동기화는 PhotonView를 반드시 거쳐가야 하기 때문에 위의 Script를 "Photon View" Script Component 의 Observed
Components에 드래그 앤 드롭으로 연결한다.
변수 동기화 예제
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
using UnityEngine.UI;
public class PlayerScript : MonoBehaviourPunCallbacks, IPunObservable // IPunObservable을 상속 받는다.
{
// ...
public Text txt;
// ...
[ContextMenu("더하기")]
public void Plus() => txt.text = (int.Parse(txt.text) + 1).ToString();
// OnPhotonSerializeView 인터페이스 구현
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
if (stream.IsWriting) stream.SendNext(txt.text); // Server에 보낼때
else txt.text = (string)stream.ReceiveNext(); // Server로부터 받을때
}
}
~> 즉, 패킷 직렬화란 메모리 상에 존재하는 데이터를 패킷에 차곡차곡 쌓은 뒤 이를 하나의 바이트 배열로 만드는 것이다.
- 역직렬화란 특정 포맷 상태의 데이터를 다시 객체로 변환하는 것을 뜻한다.
- Session은 추후에 다양하게 존재할 수 있기 때문에 Session의 이름을 정확하게 지어주는 것이 중요하다.
(예를 들어 분산서버인 경우 각 다른 부분을 관리하는 서버의 대리자 역할을 하는 Session이 여러개 존재한다.)
- 우선 Serialization 의 흐름만 이해한 뒤 추후에 자동화 할 예정이다.
DummyClient에 ServerSession Class 생성 후 DummyClient의 Program Class 와 내용 분리
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace DummyClient
{
class Packet
{
public ushort size; // ushort는 2Byte
public ushort packetId; // ushort는 2Byte
}
class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
{
public long playerId;
}
class PlayerInfoOk : Packet // Server에서 Client로 요청에 대한 답변을 전달하는 것
{
public int hp;
public int attack;
}
public enum PacketID
{
PlayerInfoReq = 1,
PlayerInfoOk = 2,
}
class ServerSession : Session
{
public override void OnConnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected : {endPoint}");
PlayerInfoReq packet = new PlayerInfoReq() { packetId = (ushort)PacketID.PlayerInfoReq, playerId = 1001 };
for (int i = 0; i < 5; i++)
{
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
// 아래 부분 추가 (2번의 단계를 거쳐야 하는 것을 TryWriteBytes를 통해 1번의 단계를 거치도록 수정)
bool success = true;
ushort count = 0; // 지금까지 몇 Byte를 Buffer에 밀어 넣었는가?
count += 2;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), packet.packetId);
count += 2;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), packet.playerId);
count += 8;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count), count); // size는 모든 작업이 끝난 뒤 초기화
// 아래 부분 삭제
//byte[] size = BitConverter.GetBytes(packet.size);
//byte[] packetId = BitConverter.GetBytes(packet.packetId);
//byte[] playerId = BitConverter.GetBytes(packet.playerId);
//Array.Copy(size, 0, openSegment.Array, openSegment.Offset + count, 2);
//count += 2;
//Array.Copy(packetId, 0, openSegment.Array, openSegment.Offset + count, 2);
//count += 2;
//Array.Copy(playerId, 0, openSegment.Array, openSegment.Offset + count, 8);
//count += 8;
ArraySegment<byte> sendBuff = SendBufferHelper.Close(count);
if (success) // success시 Send
Send(sendBuff);
}
}
public override void OnDisconnected(EndPoint endPoint)
{
Console.WriteLine($"OnDisconnected : {endPoint}");
}
public override int OnRecv(ArraySegment<byte> buffer)
{
string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
Console.WriteLine($"[From Server] {recvData}");
return buffer.Count;
}
public override void OnSend(int numOfBytes)
{
Console.WriteLine($"Transferred bytes : {numOfBytes}");
}
}
}
수정된 DummyClient의 Program Class
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using ServerCore; // Servercore 라이브러리 참조
namespace DummyClient
{
class Program
{
static void Main(string[] args)
{
// DNS (Domain Name System) : Domain을 IP 네트워크에서 찾아갈 수 있는 IP로 변환해 준다.
string host = Dns.GetHostName(); // Local Computer의 host 이름을 반환
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0]; // ip 주소를 배열로 반환 (예를 들어 Google과 같이 Traffic이 어마무시한 사이트는 여러개의 ip 주소를 가질 수 있기 때문)
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // ip 주소와 port 번호를 매개변수로 입력
Connector connector = new Connector();
connector.Connect(endPoint, () => { return new ServerSession(); });
while (true)
{
try
{
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
}
Thread.Sleep(100);
}
}
}
}
Server에 ClientSession Class 생성 후 Server의 Program Class 와 내용 분리
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Server
{
class Packet // 패킷 설계시 최대한 Size를 압축하여 보내는 것이 중요하다.
{
// Packet이 완전체로 왔는지? 잘려서 왔는지? 구분할 수 있어야 한다.
public ushort size; // ushort는 2Byte
public ushort packetId; // ushort는 2Byte
}
class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
{
public long playerId;
}
class PlayerInfoOk : Packet // Server에서 Client로 요청에 대한 답변을 전달하는 것
{
public int hp;
public int attack;
}
public enum PacketID
{
PlayerInfoReq = 1,
PlayerInfoOk = 2,
}
class ClientSession : PacketSession
{
public override void OnConnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected : {endPoint}");
Packet packet = new Packet() { size = 100, packetId = 10 };
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
byte[] buffer = BitConverter.GetBytes(packet.size);
byte[] buffer2 = BitConverter.GetBytes(packet.packetId);
// 어느 배열의? 어디서부터? 어느 배열의? 어디로? 얼마만큼?
Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length);
Array.Copy(buffer2, 0, openSegment.Array, buffer.Length, buffer2.Length);
ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length);
Send(sendBuff);
Thread.Sleep(5000);
Disconnect();
}
// OnRecvPacket 코드 수정
public override void OnRecvPacket(ArraySegment<byte> buffer)
{
ushort count = 0; // 지금까지 몇 Byte를 Buffer에 밀어 넣었는가?
ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); // ToUInt16은 Byte 배열을 ushort로 뽑아달라는 것
count += 2;
ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); // ToUInt16은 Byte 배열을 ushort로 뽑아달라는 것
count += 2;
switch ((PacketID)id)
{
case PacketID.PlayerInfoReq:
{
long playerId = BitConverter.ToInt64(buffer.Array, buffer.Offset + count); // ToUInt16은 Byte 배열을 long으로 뽑아달라는 것
count += 8;
Console.WriteLine($"PlayerInfoReq: {playerId}");
}
break;
}
Console.WriteLine($"RecvPacketID: {id}, Size: {size}");
}
public override void OnDisconnected(EndPoint endPoint)
{
Console.WriteLine($"OnDisconnected : {endPoint}");
}
public override void OnSend(int numOfBytes)
{
Console.WriteLine($"Transferred bytes : {numOfBytes}");
}
}
}
수정된 Server의 Program Class
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;
namespace Server
{
class Program
{
static Listener _listener = new Listener();
static void Main(string[] args)
{
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0];
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);
// 손님을 입장시킨다.
_listener.Init(endPoint, () => { return new ClientSession(); });
Console.WriteLine("Listening...");
while (true)
{
}
}
}
}
# Serialization #2
- 자동화 하기에 앞서 인터페이스를 통한 코드 수정을 할 예정이다.
ServerSession Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace DummyClient
{
public abstract class Packet
{
public ushort size;
public ushort packetId;
// 최상위 Class인 Packet에 인터페이스 생성
public abstract ArraySegment<byte> Write();
public abstract void Read(ArraySegment<byte> s);
}
class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
{
public long playerId;
// 생성자
public PlayerInfoReq()
{
this.packetId = (ushort)PacketID.PlayerInfoReq;
}
// 인터페이스 구현
public override ArraySegment<byte> Write()
{
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
bool success = true;
ushort count = 0; // 지금까지 몇 Byte를 Buffer에 밀어 넣었는가?
count += 2;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), this.packetId);
count += 2;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), this.playerId);
count += 8;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count), count); // size는 모든 작업이 끝난 뒤 초기화
if (success == false)
return null;
return SendBufferHelper.Close(count);
}
// 인터페이스 구현
public override void Read(ArraySegment<byte> s)
{
ushort count = 0;
//ushort size = BitConverter.ToUInt16(s.Array, s.Offset + count); ~> 사용할 일이 없어 필요 X
count += 2;
//ushort id = BitConverter.ToUInt16(s.Array, s.Offset + count); ~> Read를 실행했다는 것은 이미 패킷 분해 후 id에 대한 정보를 얻은 뒤이므로 필요 X
count += 2;
// 수정 부분 (Client가 악의적으로 잘못된 Packet Size를 보낸 경우를 방지하기 위함)
this.playerId = BitConverter.ToInt64(new ReadOnlySpan<byte>(s.Array, s.Offset + count, s.Count - count));
count += 8;
}
}
// PlayerInfoOk는 추후에 구현 예정
//class PlayerInfoOk : Packet // Server에서 Client로 요청에 대한 답변을 전달하는 것
//{
// public int hp;
// public int attack;
//}
// ...
class ServerSession : Session
{
public override void OnConnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected : {endPoint}");
PlayerInfoReq packet = new PlayerInfoReq() { playerId = 1001 };
for (int i = 0; i < 5; i++)
{
ArraySegment<byte> s = packet.Write(); // 직렬화
if (s != null) // success시 Send
Send(s);
}
}
// ...
}
}
ClientSession Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Server
{
public abstract class Packet
{
public ushort size;
public ushort packetId;
// 최상위 Class인 Packet에 인터페이스 생성
public abstract ArraySegment<byte> Write();
public abstract void Read(ArraySegment<byte> s);
}
class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
{
public long playerId;
// 생성자
public PlayerInfoReq()
{
this.packetId = (ushort)PacketID.PlayerInfoReq;
}
// 인터페이스 구현
public override ArraySegment<byte> Write()
{
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
bool success = true;
ushort count = 0; // 지금까지 몇 Byte를 Buffer에 밀어 넣었는가?
count += 2;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), this.packetId);
count += 2;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), this.playerId);
count += 8;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count), count); // size는 모든 작업이 끝난 뒤 초기화
if (success == false)
return null;
return SendBufferHelper.Close(count);
}
// 인터페이스 구현
public override void Read(ArraySegment<byte> s)
{
ushort count = 0;
//ushort size = BitConverter.ToUInt16(s.Array, s.Offset + count); ~> 사용할 일이 없어 필요 X
count += 2;
//ushort id = BitConverter.ToUInt16(s.Array, s.Offset + count); ~> Read를 실행했다는 것은 이미 패킷 분해 후 id에 대한 정보를 얻은 뒤이므로 필요 X
count += 2;
// 수정 부분 (Client가 악의적으로 잘못된 Packet Size를 보낸 경우를 방지하기 위함)
this.playerId = BitConverter.ToInt64(new ReadOnlySpan<byte>(s.Array, s.Offset + count, s.Count - count));
count += 8;
}
}
// PlayerInfoOk는 추후에 구현 예정
//class PlayerInfoOk : Packet // Server에서 Client로 요청에 대한 답변을 전달하는 것
//{
// public int hp;
// public int attack;
//}
// ...
class ClientSession : PacketSession
{
// ...
public override void OnRecvPacket(ArraySegment<byte> buffer)
{
// 패킷을 분해하여 id 에 대한 정보를 얻은 뒤
ushort count = 0;
ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
count += 2;
ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
count += 2;
// 해당 id 에 맞는 코드를 실행
switch ((PacketID)id)
{
case PacketID.PlayerInfoReq:
{
PlayerInfoReq p = new PlayerInfoReq();
p.Read(buffer); // 역직렬화
Console.WriteLine($"PlayerInfoReq: {p.playerId}");
}
break;
}
Console.WriteLine($"RecvPacketID: {id}, Size: {size}");
}
// ...
}
}
# UTF-8 vs UTF-16
- 컴퓨터가 세상에 처음 등장할 당시에는 영어와 몇가지 특수문자만을 사용하였고, 이를 저장하기 위해 1Byte로 충분했다.
~> ASCII 코드 (1Byte) 등장
- 그러나 인터넷 시대 도입 후 언어의 다양성으로 인하여 1Byte 만으로는 모든 나라의 언어를 표현할 수 없다.
~> UNICODE (2Byte) 등장
- Encoding은 컴퓨터에서 문자와 기호를 표현하기 위해 문자를 이진 데이터로 변환하는 과정이며 문자와 이진 데이터 간의
Mapping 규칙을 정의하는 방법이다.
~> Variable-Width Encoding (가변 너비 인코딩) : UTF-8, UTF-16
~> Fixed-Length Encoding (고정 길이 인코딩) : UTF-32
- UTF-8
~> 영문 : 1Byte
~> 한글 : 3Byte
- UTF-16
~> BMP X : 2Byte
~> BMP O : 4Byte
~> 영문 : 2Byte
~> 한글 : 2Byte
# Serialization #3
- 데이터의 길이가 가변적인 String은 어떻게 처리해야 할까?
String 처리를 위한 ServerSession Class 와 ClientSession Class 수정 (아래 코드는 ServerSession Class지만 ClientSession Class도 동일한 코드로 수정)
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace DummyClient
{
public abstract class Packet
{
public ushort size;
public ushort packetId;
public abstract ArraySegment<byte> Write();
public abstract void Read(ArraySegment<byte> openSegment);
}
class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
{
public long playerId;
public string name; // 가변 길이의 멤버 변수는 어떻게 처리?
// 생성자
public PlayerInfoReq()
{
this.packetId = (ushort)PacketID.PlayerInfoReq;
}
public override ArraySegment<byte> Write()
{
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
bool success = true;
ushort count = 0;
Span<byte> span = new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);
count += sizeof(ushort);
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.packetId); // Slice는 실질적으로 Span에 변화를 주지 X
count += sizeof(ushort);
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.playerId); // Slice는 실질적으로 Span에 변화를 주지 X
count += sizeof(long);
// string 처리 #1 (Buffer에 2Byte인 string len을 먼저 삽입 후 string data 삽입)
//ushort nameLen = (ushort)Encoding.Unicode.GetByteCount(this.name); // GetByteCount()는 UTF-16 기준의 byte 배열 크기를 반환
//success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), nameLen); // Slice는 실질적으로 Span에 변화를 주지 X
//count += sizeof(ushort);
//Array.Copy(Encoding.Unicode.GetBytes(this.name), 0, openSegment.Array, count, nameLen); // GetBytes()는 string을 받아 Byte 배열로 변환
//count += nameLen;
// string 처리 #2 (Buffer에 2Byte인 string len을 위한 공간을 남겨둔 채로 string data를 먼저 삽입 후 string len 삽입)
ushort nameLen = (ushort)Encoding.Unicode.GetBytes(this.name, 0, name.Length, openSegment.Array, openSegment.Offset + count + sizeof(ushort)); // Buffer에 string data를 삽입함과 동시에 string len을 반환
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), nameLen);
count += sizeof(ushort);
count += nameLen;
success &= BitConverter.TryWriteBytes(span, count); // size는 모든 작업이 끝난 뒤 초기화
if (success == false)
return null;
return SendBufferHelper.Close(count);
}
public override void Read(ArraySegment<byte> openSegment)
{
ushort count = 0;
ReadOnlySpan<byte> span = new ReadOnlySpan<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);
count += sizeof(ushort);
count += sizeof(ushort);
this.playerId = BitConverter.ToInt64(span.Slice(count, span.Length - count)); // Slice는 실질적으로 Span에 변화를 주지 X
count += sizeof(long);
// string 처리
ushort nameLen = BitConverter.ToUInt16(span.Slice(count, span.Length - count));
count += sizeof(ushort);
this.name = Encoding.Unicode.GetString(span.Slice(count, nameLen)); // GetString()는 Byte 배열을 받아 string으로 변환
count += nameLen;
}
}
// ...
}
# Serialization #4
- 데이터의 길이가 가변적인 List는 어떻게 처리해야 할까?
List 처리를 위한 ServerSession Class 와 ClientSession Class 수정 (아래 코드는 ServerSession Class지만 ClientSession Class도 동일한 코드로 수정)
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace DummyClient
{
public abstract class Packet
{
public ushort size;
public ushort packetId;
public abstract ArraySegment<byte> Write();
public abstract void Read(ArraySegment<byte> openSegment);
}
class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
{
public long playerId;
public string name;
public struct SkillInfo
{
public int id;
public short level;
public float duration;
public bool Write(Span<byte> span, ref ushort count) // span은 전체 Byte 배열을, count는 실시간으로 현재 어느 곳을 작업하는지
{
bool success = true;
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), id);
count += sizeof(int);
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), level);
count += sizeof(short);
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), duration);
count += sizeof(float);
return success;
}
public void Read(ReadOnlySpan<byte> span, ref ushort count)
{
id = BitConverter.ToInt32(span.Slice(count, span.Length - count));
count += sizeof(int);
level = BitConverter.ToInt16(span.Slice(count, span.Length - count));
count += sizeof(short);
duration = BitConverter.ToSingle(span.Slice(count, span.Length - count));
count += sizeof(float);
}
}
public List<SkillInfo> skills = new List<SkillInfo>(); // 가변 길이의 멤버 변수는 어떻게 처리?
// 생성자
public PlayerInfoReq()
{
this.packetId = (ushort)PacketID.PlayerInfoReq;
}
public override ArraySegment<byte> Write()
{
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
bool success = true;
ushort count = 0;
Span<byte> span = new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);
count += sizeof(ushort);
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.packetId);
count += sizeof(ushort);
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.playerId);
count += sizeof(long);
// string 처리
ushort nameLen = (ushort)Encoding.Unicode.GetBytes(this.name, 0, name.Length, openSegment.Array, openSegment.Offset + count + sizeof(ushort)); // Buffer에 string data를 삽입함과 동시에 string len을 반환
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), nameLen);
count += sizeof(ushort);
count += nameLen;
// list 처리
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), (ushort)skills.Count);
count += sizeof(ushort);
foreach (SkillInfo skill in skills)
success &= skill.Write(span, ref count);
success &= BitConverter.TryWriteBytes(span, count); // size는 모든 작업이 끝난 뒤 초기화
if (success == false)
return null;
return SendBufferHelper.Close(count);
}
public override void Read(ArraySegment<byte> openSegment)
{
ushort count = 0;
ReadOnlySpan<byte> span = new ReadOnlySpan<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);
count += sizeof(ushort);
count += sizeof(ushort);
this.playerId = BitConverter.ToInt64(span.Slice(count, span.Length - count));
count += sizeof(long);
// string 처리
ushort nameLen = BitConverter.ToUInt16(span.Slice(count, span.Length - count));
count += sizeof(ushort);
this.name = Encoding.Unicode.GetString(span.Slice(count, nameLen));
count += nameLen;
// list 처리
skills.Clear();
ushort skillLen = BitConverter.ToUInt16(span.Slice(count, span.Length - count));
count += sizeof(ushort);
for (int i = 0; i < skillLen; i++)
{
SkillInfo skill = new SkillInfo();
skill.Read(span, ref count);
skills.Add(skill);
}
}
}
// ...
}
# Packet Generator #1
-[ 솔루션 ] - [ 오른쪽 마우스 ] - [ 추가 ] - [ 새 솔루션 폴더 ] 를 통해 폴더를 추가할 수 있다.
- [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 추가 ] - [ 새 항목 ] 의 [ C# 항목 ] - [ 데이터 ] 에서 XML 파일을 추가할 수 있다.
~> 생성된 XML 파일은 [프로젝트 ] - [ 오른쪽 마우스 ] - [ 파일 탐색기에서 폴더 열기 ] - [ bin ] - [ Debug ] - [ net ] 안에
위치하도록 한다. (즉, 해당 프로젝트의 실행파일이 있는 곳에 위치하도록)
- 패킷의 정의를 어떤 방식으로 할지 결정해야 한다. (JSON, XML, 자체 정의 IDL)
~> XML이 JSON에 비해 Hierarchy가 잘 보인다는 장점을 가지므로 XML을 사용할 것이다.
~> XML에서 정보는 시작 Tag와 끝 Tag 사이에 담겨지며, Tag는 쌍을 이룬다.
(Tag 사이에 삽입할 정보가 없는 경우 시작 Tag 끝에 /를 추가하여 끝 Tag 생략 가능)
-Parsing은 컴퓨터 과학 및 프로그래밍에서 특정 형식으로 구성된 데이터를 분석하고 그 의미를 이해하는 과정을 의미
- Parsing은 주로 텍스트 기반 데이터를 해석하거나, 프로그래밍 언어의 소스 코드를 이해하거나, 문서를 구조화하고
내용을 추출하는 데 사용
# Packet Generator #2
- 지난시간 제외한 List 부분을 자동화하기 위한 Template을 만들고, Packet Generator가 잘 실행되는지 확인하고자 한다.
~> Packet Generator의 결과를 "GenPackets.cs" 파일에 저장하였다.
~> "GenPackets.cs" 파일은 [프로젝트 ] - [ 오른쪽 마우스 ]- [ 파일 탐색기에서 폴더 열기 ] - [ bin ] - [ Debug ] - [ net ]
에서 확인할 수 있다.
PacketFormat Class 수정
using System;
using System.Collections.Generic;
using System.Text;
namespace PacketGenerator
{
class PacketFormat
{
// ...
// {0} 리스트 이름 [대문자]
// {1} 리스트 이름 [소문자]
// {2} 멤버 변수들
// {3} 멤버 변수 Read
// {4} 멤버 변수 Write
public static string memberListFormat =
@"
public struct {0}
{{
{2}
public void Read(ReadOnlySpan<byte> span, ref ushort count)
{{
{3}
}}
public bool Write(Span<byte> span, ref ushort count)
{{
bool success = true;
{4}
return success;
}}
}}
public List<{0}> {1}s = new List<{0}>();
";
// ...
// {0} 리스트 이름 [대문자]
// {1} 리스트 이름 [소문자]
public static string readListFormat =
@"
this.{1}s.Clear();
ushort {1}Len = BitConverter.ToUInt16(span.Slice(count, span.Length - count));
count += sizeof(ushort);
for (int i = 0; i < {1}Len; i++)
{{
{0} {1} = new {0}();
{1}.Read(span, ref count);
{1}s.Add({1});
}}
";
// ...
// {0} 리스트 이름 [대문자]
// {1} 리스트 이름 [소문자]
public static string writeListFormat =
@"
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), (ushort)this.{1}s.Count);
count += sizeof(ushort);
foreach ({0} {1} in this.{1}s)
success &= {1}.Write(span, ref count);
";
}
}
PacketGenerator 코드 수정
using System.Xml;
namespace PacketGenerator
{
internal class Program
{
static string genPackets; // 실시간으로 만들어지는 패킷
static void Main(string[] args)
{
XmlReaderSettings settings = new XmlReaderSettings()
{
IgnoreComments= true, // 주석을 무시
IgnoreWhitespace = true // 공백을 무시
};
// XML 파싱
using (XmlReader reader = XmlReader.Create("PDL.xml", settings))
{
reader.MoveToContent(); // header를 건너뛰고 내용 부분으로 이동
while (reader.Read()) // 한줄씩 읽어나간다.
{
if (reader.Depth == 1 && reader.NodeType == XmlNodeType.Element) // Element는 시작 부분, EndElement는 끝 부분
ParsePacket(reader);
// Console.WriteLine(reader.Name + " " + reader["name"]); // Name은 Type을 반환, []는 Attribute를 반환
}
}
File.WriteAllText("GenPackets.cs", genPackets); // genPackets의 내용을 통해 GenPackets.cs 파일 생성
}
public static void ParsePacket(XmlReader reader)
{
if (reader.NodeType == XmlNodeType.EndElement)
return;
if (reader.Name.ToLower() != "packet")
{
Console.WriteLine("Invalid packet node");
return;
}
string packetName = reader["name"];
if (string.IsNullOrEmpty(packetName) )
{
Console.WriteLine("Packet without name");
return;
}
Tuple<string, string, string> tuple = ParseMembers(reader);
genPackets += string.Format(PacketFormat.packetFormat,
packetName, tuple.Item1, tuple.Item2, tuple.Item3);
}
// 멤버 변수들, 멤버 변수 Read, 멤버 변수 Write 에 관한 코드를 알맞게 제작한 뒤 이를 string으로 반환
public static Tuple<string, string, string> ParseMembers(XmlReader reader)
{
string packetName = reader["name"];
string memberCode = "";
string readCode = "";
string writeCode = "";
int depth = reader.Depth + 1;
while(reader.Read())
{
if (reader.Depth != depth)
break;
string memberName = reader["name"];
if (string.IsNullOrEmpty(memberName) )
{
Console.WriteLine("Member without name");
return null;
}
if (string.IsNullOrEmpty(memberCode) == false) // 이미 내용이 존재하는 경우
memberCode += Environment.NewLine; // Enter를 치는 것과 같은 동작
if(string.IsNullOrEmpty(readCode) == false) // 이미 내용이 존재하는 경우
readCode += Environment.NewLine; // Enter를 치는 것과 같은 동작
if(string.IsNullOrEmpty(writeCode) == false) // 이미 내용이 존재하는 경우
writeCode += Environment.NewLine; // Enter를 치는 것과 같은 동작
string memberType = reader.Name.ToLower();
switch (memberType)
{
case "bool":
case "short":
case "ushort":
case "int":
case "long":
case "float":
case "double":
memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName);
readCode += string.Format(PacketFormat.readFormat, memberName, ToMemberType(memberType), memberType);
writeCode += string.Format(PacketFormat.writeFormat, memberName, memberType);
break;
case "string":
memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName);
readCode += string.Format(PacketFormat.readStringFormat, memberName);
writeCode += string.Format(PacketFormat.writeStringFormat, memberName);
break;
case "list":
Tuple<string, string, string> tuple = ParseList(reader);
memberCode += tuple.Item1;
readCode += tuple.Item2;
writeCode += tuple.Item3;
break;
default:
break;
}
}
// 가독성을 위해 Text를 정렬
memberCode = memberCode.Replace("\n", "\n\t"); // Enter가 입력된 곳은 Enter 입력 후 Tab 까지 입력되도록 수정
readCode = readCode.Replace("\n", "\n\t\t"); // Enter가 입력된 곳은 Enter 입력 후 Tab Tab 까지 입력되도록 수정
writeCode = writeCode.Replace("\n", "\n\t\t"); // Enter가 입력된 곳은 Enter 입력 후 Tab Tab 까지 입력되도록 수정
return new Tuple<string, string, string>(memberCode, readCode, writeCode);
}
public static Tuple<string, string, string> ParseList(XmlReader reader)
{
string listName = reader["name"];
if (string.IsNullOrEmpty(listName))
{
Console.WriteLine("List without name");
return null;
}
// memberListFormat의 {2}, {3}, {4}는 순서대로 멤버 변수들, 멤버 변수 Read, 멤버 변수 Write 이므로 ParseMembers() 함수 사용
Tuple<string, string, string> tuple = ParseMembers(reader);
string memberCode = string.Format(PacketFormat.memberListFormat,
FirstCharToUpper(listName), FirstCharToLower(listName),
tuple.Item1, tuple.Item2, tuple.Item3);
string readCode = string.Format(PacketFormat.readListFormat,
FirstCharToUpper(listName), FirstCharToLower(listName));
string writeCode = string.Format(PacketFormat.writeListFormat,
FirstCharToUpper(listName), FirstCharToLower(listName));
return new Tuple <string, string, string> (memberCode, readCode, writeCode);
}
public static string FirstCharToUpper(string input)
{
if (string.IsNullOrEmpty(input))
return "";
return input[0].ToString().ToUpper() + input.Substring(1);
}
public static string FirstCharToLower(string input)
{
if (string.IsNullOrEmpty(input))
return "";
return input[0].ToString().ToLower() + input.Substring(1);
}
public static string ToMemberType(string memberType)
{
switch (memberType)
{
case "bool":
return "ToBoolean";
case "short":
return "ToInt16";
case "ushort":
return "ToUInt16";
case "int":
return "ToInt32";
case "long":
return "ToInt64";
case "float":
return "ToSingle";
case "double":
return "ToDouble";
default:
return "";
}
}
}
}
# Packet Generator #3
- using 과 enum 및 byte 부분을 자동화하기 위한 Template을 만들고, 패킷이 여러개 존재해도 자동화가 잘 되는지
using System;
using System.Collections.Generic;
using System.Text;
namespace PacketGenerator
{
class PacketFormat
{
// {0} 패킷 이름/번호 목록
// {1} 패킷 목록
public static string fileFormat =
@"
using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using ServerCore;
public enum PacketID
{{
{0}
}}
{1}
";
// {0} 패킷 이름
// {1} 패킷 번호
public static string packetEnumFormat =
@"{0} = {1},";
// ...
// {0} 변수 이름
// {1} 변수 형식
public static string readByteFormat =
@"
this.{0} = ({1})openSegment.Array[openSegment.Offset + count];
count += sizeof({1});
";
// ...
// {0} 변수 이름
// {1} 변수 형식
public static string writeByteFormat =
@"
openSegment.Array[openSegment.Offset + count] = (byte)this.{0};
count += sizeof({1});
";
// ...
}
}
PacketGenerator 코드 수정
using System.Xml;
namespace PacketGenerator
{
internal class Program
{
// ...
static void Main(string[] args)
{
// ...
static ushort packetId; // 몇개의 패킷을 처리하였는지
static string packetEnums; // Parsing 처리된 패킷의 이름과 번호
// fileFormat을 통해 using과 enum 추가
string fileText = string.Format(PacketFormat.fileFormat, packetEnums, genPackets);
File.WriteAllText("GenPackets.cs", fileText);
}
public static void ParsePacket(XmlReader reader)
{
// ...
Tuple<string, string, string> tuple = ParseMembers(reader);
genPackets += string.Format(PacketFormat.packetFormat,
packetName, tuple.Item1, tuple.Item2, tuple.Item3);
// 패킷 1개를 Parsing 할때마다 packetEnums에 Parsing 처리된 패킷의 이름과 번호를 저장
packetEnums += string.Format(PacketFormat.packetEnumFormat, packetName, ++packetId) + Environment.NewLine + "\t";
}
// 멤버 변수들, 멤버 변수 Read, 멤버 변수 Write 에 관한 코드를 알맞게 제작한 뒤 이를 string으로 반환
public static Tuple<string, string, string> ParseMembers(XmlReader reader)
{
string packetName = reader["name"];
string memberCode = "";
string readCode = "";
string writeCode = "";
int depth = reader.Depth + 1;
while(reader.Read())
{
// ...
if (string.IsNullOrEmpty(memberCode) == false) // 이미 내용이 존재하는 경우
memberCode += Environment.NewLine; // Enter를 치는 것과 같은 동작
if(string.IsNullOrEmpty(readCode) == false) // 이미 내용이 존재하는 경우
readCode += Environment.NewLine; // Enter를 치는 것과 같은 동작
if(string.IsNullOrEmpty(writeCode) == false) // 이미 내용이 존재하는 경우
writeCode += Environment.NewLine; // Enter를 치는 것과 같은 동작
string memberType = reader.Name.ToLower();
switch (memberType)
{
case "byte":
case "sbyte":
memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName);
readCode += string.Format(PacketFormat.readByteFormat, memberName, memberType);
writeCode += string.Format(PacketFormat.writeByteFormat, memberName, memberType);
break;
case "bool":
case "short":
case "ushort":
case "int":
case "long":
case "float":
case "double":
memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName);
readCode += string.Format(PacketFormat.readFormat, memberName, ToMemberType(memberType), memberType);
writeCode += string.Format(PacketFormat.writeFormat, memberName, memberType);
break;
case "string":
memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName);
readCode += string.Format(PacketFormat.readStringFormat, memberName);
writeCode += string.Format(PacketFormat.writeStringFormat, memberName);
break;
case "list":
Tuple<string, string, string> tuple = ParseList(reader);
memberCode += tuple.Item1;
readCode += tuple.Item2;
writeCode += tuple.Item3;
break;
default:
break;
}
}
// ...
}
// ...
}
}
# Packet Generator #4
- Packet Generator의 결과가 저장된 "GenPackets.cs" 파일의 내용을 수동으로 ServerSession 과 ClientSession class에
추가하였는데 해당 부분을 자동화하기 위한 Template을 만들고자 한다.
- 출력 경로는 [프로젝트 ] - [ 오른쪽 마우스 ] - [ 속성 ] - [ 빌드 ] - [ 일반 ] 의 [ 출력 ] 에서 설정 가능하다.
~> 그 후 [프로젝트 ] - [ 오른쪽 마우스 ] -[ 파일 탐색기에서 폴더 열기 ] 에서
"프로젝트이름.csproj" 파일을 메모장으로 연 뒤 <PropertyGroup></PropertyGroup> 사이에
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> 를 추가하고,
<BaseOutputPath>출력 경로</BaseOutputPath> 를 <OutputPath>출력 경로</OutputPath> 로 수정한다.
~> 모든 설정이 끝난 뒤 [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 빌드 ] 시 설정한 출력 경로에 실행파일이 생성된다.
- 배치 파일은 명령 인터프리터에 의해 실행되게끔 공안된 명령어들이 나열된 텍스트 파일이다.
~> .bat 또는 .cmd 형식의 확장자 파일을 직접 실행하거나 명령 프롬프트에서 배치 파일의 이름으로 실행할 수 있다.
~> 배치파일을 통해[프로젝트 ] - [ 오른쪽 마우스 ] -[ 파일 탐색기에서 폴더 열기 ] - [ bin ] 내의 프로젝트 실행파일을
대신 실행한 뒤 실행 파일의 결과에 저장된 내용을 자동으로 복사할 예정이다. (출력 경로를 /bin 으로 설정)
~> START는 응용 프로그램 실행 명령어로 실행하고자 하는 파일의 경로와 인자를 넘겨준다.
~> XCOPY는 파일 복사 명령어로 복사 대상과 복사 위치를 넘겨준다.
(/Y 옵션은 같은 파일이 있는 경우 무조건 덮어쓴다는 것이다.)
GenPackets.bat 배치 파일 생성 (배치 파일은 [ 솔루션 ] - [ Common ] - [ Packet ] 내에 존재)
(배치 파일 실행시 PDL.xml을 인자로 넘긴 Packet Generator가 실행되어 DummyClient/Packet 과 Server/Packet 산하의 GenPackets에 Packet Generator의 결과가 저장된 "GenPackets.cs" 파일의 내용이 자동으로 복사된다.)
using System.Xml;
namespace PacketGenerator
{
internal class Program
{
// ...
static void Main(string[] args)
{
string pdlPath = "../PDL.xml"; // bin 폴더가 아닌 PacketGenerator 폴더에 있는 PDL.xml 찾기 위한 것 (실행 파일이 위치한 곳 기준으로 이동)
// ...
if (args.Length >= 1) // 프로그램 실행시 인자로 무언가를 넘겨준 경우
pdlPath = args[0]; // pdlPath를 전달받은 인자로 초기화
using (XmlReader reader = XmlReader.Create(pdlPath, settings))
{
// ...
}
// ...
}
// ...
}
}
# Packet Generator #5
- 게임 규모가 커질수록 패킷의 종류는 상당히 많아진다.
~> 즉, ClientSession Class의 OnRecvPacket() 함수 안의 switch-case문 길이가 상당히 길어질 수 있다.
- switch-case문 및 OnRecvPacket() 함수 자동화를 위해 모든 패킷들이 base interface를 상속받도록 한다.
~> 모든 패킷에 대해 공통 인수로 넘길 수 있기 때문에 편리하다는 장점을 가진다.
PacketFormat Class 수정
using System;
using System.Collections.Generic;
using System.Text;
namespace PacketGenerator
{
class PacketFormat
{
// {0} 패킷 이름/번호 목록
// {1} 패킷 목록
public static string fileFormat =
@"
using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using ServerCore;
public enum PacketID
{{
{0}
}}
interface IPacket
{{
ushort Protocol {{ get; }}
void Read(ArraySegment<byte> openSegment);
ArraySegment<byte> Write();
}}
{1}
";
// ...
// {0} 패킷 이름
// {1} 멤버 변수들
// {2} 멤버 변수 Read
// {3} 멤버 변수 Write
public static string packetFormat =
@"
public class {0} : IPacket
{{
{1}
public ushort Protocol {{ get {{ return (ushort)PacketID.{0}; }} }}
public void Read(ArraySegment<byte> openSegment)
{{
ushort count = 0;
ReadOnlySpan<byte> span = new ReadOnlySpan<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);
count += sizeof(ushort);
count += sizeof(ushort);
{2}
}}
public ArraySegment<byte> Write()
{{
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
bool success = true;
ushort count = 0;
Span<byte> span = new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);
count += sizeof(ushort);
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), (ushort)PacketID.{0});
count += sizeof(ushort);
{3}
success &= BitConverter.TryWriteBytes(span, count);
if (success == false)
return null;
return SendBufferHelper.Close(count);
}}
}}
";
// ...
}
}
DummyClient/Packet 과 Server/Packet 산하에 PacketHandler Class 생성
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
namespace Server
{
class PacketHandler
{
// 해당 패킷이 전부 조립된 경우 무엇을 할까?
// PacketHandler는 자동화 없이 수동으로 추가
public static void PlayerInfoReqHandler(PacketSession session, IPacket packet)
{
PlayerInfoReq p = packet as PlayerInfoReq;
Console.WriteLine($"PlayerInfoReq: {p.playerId} {p.name}");
foreach (PlayerInfoReq.Skill skill in p.skills)
{
Console.WriteLine($"Skill({skill.id})({skill.level})({skill.duration})");
}
}
}
}
DummyClient/Packet 과 Server/Packet 산하에 PacketManager Class 생성
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
namespace Server
{
class PacketManager
{
// PacketManager는 Singleton 패턴 사용
#region Singleton
static PacketManager _instance;
public static PacketManager Instance
{
get {
if (_instance == null)
_instance = new PacketManager();
return _instance;
}
}
#endregion
// 구분하기 위한 Protocol ID, 어떤 작업을 수행할지
Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>> _onRecv = new Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>>();
// 구분하기 위한 Protocol ID, 어떤 Handler를 호출할지
Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>();
public void Register() // 추후 자동화할 예정
{
_onRecv.Add((ushort)PacketID.PlayerInfoReq, MakePacket<PlayerInfoReq>);
_handler.Add((ushort)PacketID.PlayerInfoReq, PacketHandler.PlayerInfoReqHandler);
}
public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer)
{
// 패킷을 분해하여 id 에 대한 정보를 얻은 뒤
ushort count = 0;
ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
count += 2;
ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
count += 2;
// 이젠 switch-case문이 아닌 Dictionary에서 찾아 Invoke()
Action<PacketSession, ArraySegment<byte>> action = null;
if (_onRecv.TryGetValue(id, out action))
action.Invoke(session, buffer);
}
void MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new()
{
T packet = new T();
packet.Read(buffer); // 역직렬화
// Dictionary에서 찾아 Invoke()
Action<PacketSession, IPacket> action = null;
if (_handler.TryGetValue(packet.Protocol, out action))
action.Invoke(session, packet);
}
}
}
ClientSession Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Server
{
class ClientSession : PacketSession
{
// ...
public override void OnRecvPacket(ArraySegment<byte> buffer)
{
PacketManager.Instance.OnRecvPacket(this, buffer);
}
// ...
}
}
Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;
namespace Server
{
class Program
{
// ...
static void Main(string[] args)
{
// MultiThread가 개입하지 않는 부분에서 실행
PacketManager.Instance.Register();
// ...
}
}
}
# Packet Generator #6
- PacketManager 를 자동화하기 위한 Template을 만들고자 한다.
~> 양방향 패킷은 거의 존재하지 않는다. (대부분 Client에서 Server 또는 Server에서 Client)
~> 그러나 분산 Server인 경우 Client와 Server만 소통하는 것이 아닌 Server 끼리도 소통한다.
~> 패킷 이름에 규칙을 설정하고 이를 통해 파일을 분리한다.
(패킷 이름 앞에 C_ 가 붙은 것은 Client에서 Server로, S_ 가 붙은 것은 Server에서 Client로)
(PacketManager 를 자동화할때 Server 쪽에 추가되는 PacketManager의 Register에는 C_가 붙은 것들만, DummyClient 쪽에 추가되는 PacketManager의 Register에는 S_가 붙은 것들만 등록한다.)
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
// ...
public abstract class Session
{
// ...
void Clear() // _sendQueue 와 _pendingList 를 초기화하기 위한 함수 추가
{
lock (_lock)
{
_sendQueue.Clear();
_pendingList.Clear();
}
}
// ...
public void Disconnect()
{
// ...
Clear(); // _sendQueue 와 _pendingList 를 초기화
}
void RegisterSend()
{
if (_disconnected == 1) // 최소한의 예방책
return;
while (_sendQueue.Count > 0)
{
ArraySegment<byte> buff = _sendQueue.Dequeue();
_pendingList.Add(buff);
}
_sendArgs.BufferList = _pendingList;
// socket을 다루는 부분을 try-catch문으로 감싸준다. (MultiThread 환경을 위한 예방책)
// ~> 누군가는 위의 if문을 통과하여 아래 부분을 마저 실행하려고 하는 도중에 다른 Thread에서 socket을 disconnect시 문제가 발생하기 때문
try
{
bool pending = _socket.SendAsync(_sendArgs);
if (pending == false)
OnSendCompleted(null, _sendArgs);
}
catch (Exception e)
{
Console.WriteLine($"RegisterSend Failed {e}");
}
}
// ...
void RegisterRecv()
{
if (_disconnected == 1) // 최소한의 예방책
return;
_recvBuffer.Clean();
ArraySegment<byte> segment = _recvBuffer.WriteSegment;
_recvArgs.SetBuffer(segment.Array, segment.Offset, segment.Count);
// socket을 다루는 부분을 try-catch문으로 감싸준다. (MultiThread 환경을 위한 예방책)
// ~> 누군가는 위의 if문을 통과하여 아래 부분을 마저 실행하려고 하는 도중에 다른 Thread에서 socket을 disconnect시 문제가 발생하기 때문
try
{
bool pending = _socket.ReceiveAsync(_recvArgs);
if (pending == false)
OnRecvCompleted(null, _recvArgs);
}
catch(Exception e)
{
Console.WriteLine($"RegisterRecv Failed {e}");
}
}
// ...
}
}
Server에 GameRoom Class 생성
using System;
using System.Collections.Generic;
using System.Text;
namespace Server
{
class GameRoom
{
List<ClientSession> _sessions = new List<ClientSession>(); // GameRoom에 존재하는 session들
object _lock = new object(); // List나 Dictionary 등 대부분의 자료 구조들은 MultiThread 환경에서 잘 돌아간다는 보장이 없기 때문에 lock 생성
public void Broadcast(ClientSession session, string chat) // 현재 session이 접속중인 방에 존재하는 모두에게 chat을 뿌린다.
{
S_Chat packet = new S_Chat();
packet.playerId = session.SessionId;
packet.chat = chat;
ArraySegment<byte> segment = packet.Write();
lock(_lock)
{
foreach (ClientSession s in _sessions)
s.Send(segment);
}
}
public void Enter(ClientSession session) // 방 입장
{
lock (_lock)
{
_sessions.Add(session);
session.Room = this;
}
}
public void Leave(ClientSession session) // 방 퇴장
{
lock (_lock)
{
_sessions.Remove(session);
}
}
}
}
Server에 SessionManager Class 생성
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Server
{
class SessionManager // SessionManager는 Engine 쪽에서 관리해도 되고, Content 쪽에서 관리해도 된다. (선택의 차이)
{
// SessionManager는 Singleton 패턴 사용
static SessionManager _session = new SessionManager();
public static SessionManager Instance { get { return _session; } }
int _sessionId = 0; // session을 구분하기 위한 Id
Dictionary<int, ClientSession> _sessions = new Dictionary<int, ClientSession>(); // 현재 존재하는 session들
object _lock = new object(); // List나 Dictionary 등 대부분의 자료 구조들은 MultiThread 환경에서 잘 돌아간다는 보장이 없기 때문에 lock 생성
public ClientSession Generate() // session 생성
{
lock (_lock)
{
int sessionId = ++_sessionId;
ClientSession session = new ClientSession();
session.SessionId = sessionId;
_sessions.Add(sessionId, session);
Console.WriteLine($"Connected : {sessionId}");
return session;
}
}
public ClientSession Find(int id) // sessionId를 통해 session을 찾는 함수
{
lock (_lock)
{
ClientSession session = null;
_sessions.TryGetValue(id, out session);
return session;
}
}
public void Remove(ClientSession session) // session 삭제
{
lock (_lock)
{
_sessions.Remove(session.SessionId);
}
}
}
}
Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;
namespace Server
{
class Program
{
static Listener _listener = new Listener();
public static GameRoom Room = new GameRoom(); // GameRoom 생성 (딴 곳에서도 접근이 가능하도록 public으로 생성)
static void Main(string[] args)
{
PacketManager.Instance.Register();
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0];
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);
// 손님을 입장시킨다.
_listener.Init(endPoint, () => { return SessionManager.Instance.Generate(); }); // new를 통해 Session을 생성하는 것이 아닌 SessionManager를 통해 생성하도록 수정
Console.WriteLine("Listening...");
while (true)
{
}
}
}
}
ClientSession 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Server
{
class ClientSession : PacketSession
{
public int SessionId { get; set; } // Session 구분을 위해
public GameRoom Room { get; set; } // 현재 어떤 방에 위치하는지 알기 위해
public override void OnConnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected : {endPoint}");
Program.Room.Enter(this); // Client가 접속시 방에 입장시킨다. (Program 산하에 static으로 Room을 생성하였기 때문에 다음과 같이 호출)
Thread.Sleep(5000);
Disconnect();
}
// ...
public override void OnDisconnected(EndPoint endPoint)
{
SessionManager.Instance.Remove(this); // 내 자신을 (즉, session을) sessionManager를 통해 삭제 요청
if (Room != null) //
{
Room.Leave(this);
Room = null;
}
Console.WriteLine($"OnDisconnected : {endPoint}");
}
// ...
}
}
Server의 PacketHandler 수정
using Server;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
class PacketHandler
{
// 해당 패킷이 전부 조립된 경우 무엇을 할까?
// PacketHandler는 자동화 없이 수동으로 추가
public static void C_ChatHandler(PacketSession session, IPacket packet)
{
C_Chat chatPacket = packet as C_Chat;
ClientSession clientSession = session as ClientSession;
if (clientSession.Room == null)
return;
clientSession.Room.Broadcast(clientSession, chatPacket.chat); // 현재 clientSession이 접속중인 방에 존재하는 모두에게 채팅 메시지를 뿌린다.
}
}
# 채팅 테스트 #2
- 채팅 테스트를 위해Client 입장에서코드 추가 및 수정을 할 예정이다.
~> 현재 1명의 유저만 접속하고 있는 상황이므로, 이를 다수의 유저들이 접속하는 상황으로 가정하고 변경할 예정이다.
- 기존 Server의 Register 부분을 생성자를 통해 자동으로 생성하도록 변경할 예정이다.
ServerSession Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace DummyClient
{
class ServerSession : PacketSession // Session이 아닌 PacketSession 을 상속 받도록 수정
{
public override void OnConnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected : {endPoint}");
}
public override void OnDisconnected(EndPoint endPoint)
{
Console.WriteLine($"OnDisconnected : {endPoint}");
}
public override void OnRecvPacket(ArraySegment<byte> buffer) // PacketSession 을 상속 받으므로 OnRecv가 아닌 OnRecvPacket으로 수정 (반환값도 int가 아닌 void로)
{
PacketManager.Instance.OnRecvPacket(this, buffer);
}
public override void OnSend(int numOfBytes)
{
// Console.WriteLine($"Transferred bytes : {numOfBytes}"); ~> session이 많아지면 OnSend() 가 자주 호출되므로 일단은 출력되지 않도록 주석 처리
}
}
}
ServerCore의 Connector 수정
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace ServerCore
{
public class Connector
{
Func<Session> _sessionFactory;
public void Connect(IPEndPoint endPoint, Func<Session> sessionFactory, int count = 1) // 다수의 Client 환경에서 Test 하고 싶을 수도 있기 때문에
{
for (int i = 0; i < count; i++) // 입력받은 매개변수 count 만큼 아래의 과정을 반복하도록 수정
{
// 휴대폰 설정
Socket socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
_sessionFactory = sessionFactory;
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
args.Completed += OnConnectCompleted;
args.RemoteEndPoint = endPoint;
args.UserToken = socket;
RegisterConnect(args);
}
}
// ...
}
}
DummyClient에 SessionManager Class 생성
using System;
using System.Collections.Generic;
using System.Text;
namespace DummyClient
{
class SessionManager
{
// SessionManager는 Singleton 패턴 사용
static SessionManager _session = new SessionManager();
public static SessionManager Instance { get { return _session; } }
List<ServerSession> _sessions = new List<ServerSession>(); // 현재 존재하는 session들
object _lock = new object(); // List나 Dictionary 등 대부분의 자료 구조들은 MultiThread 환경에서 잘 돌아간다는 보장이 없기 때문에 lock 생성
public void SendForEach() // Server쪽으로 채팅 패킷을 전송
{
foreach (ServerSession session in _sessions)
{
C_Chat chatPacket = new C_Chat();
chatPacket.chat = $"Hello Server!";
ArraySegment<byte> segment = chatPacket.Write();
session.Send(segment);
}
}
public ServerSession Generate() // session 생성
{
lock (_lock)
{
ServerSession session = new ServerSession();
_sessions.Add(session);
return session;
}
}
}
}
DummyClient 코드 수정
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using ServerCore;
namespace DummyClient
{
class Program
{
static void Main(string[] args)
{
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0];
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);
Connector connector = new Connector();
connector.Connect(endPoint, () => { return SessionManager.Instance.Generate(); }, 10); // new를 통해 Session을 생성하는 것이 아닌 SessionManager를 통해 생성하도록 수정, 원하는 Client 수를 인자로 넘김
while (true)
{
try
{
SessionManager.Instance.SendForEach(); // 모든 Session들이 Server쪽으로 계속해서 채팅 패킷을 쏘도록
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
}
Thread.Sleep(250); // 0.25초 휴식
}
}
}
}
DummyClient의 PacketHandler 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
class PacketHandler
{
// C_Chat을 Server에 보낸뒤, Server는 방에 있는 모든 애들에게 S_Chat으로 답장을 주는 부분을 다룬다.
public static void S_ChatHandler(PacketSession session, IPacket packet)
{
S_Chat chatPacket = packet as S_Chat;
ServerSession serverSession = session as ServerSession;
Console.WriteLine(chatPacket.chat);
}
}
PacketFormat Class 수정 (Main에서 PacketManager.Instance.Register(); 를 직접 입력하는 것이 아닌 생성자를 통해 자동으로 생성되도록 수정)
using System;
using System.Collections.Generic;
using System.Text;
namespace PacketGenerator
{
class PacketFormat
{
// {0} 패킷 등록
public static string managerFormat =
@"
using ServerCore;
using System;
using System.Collections.Generic;
class PacketManager
{{
#region Singleton
static PacketManager _instance = new PacketManager();
public static PacketManager Instance {{ get {{ return _instance; }} }}
#endregion
PacketManager()
{{
Register();
}}
Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>> _onRecv = new Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>>();
Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>();
public void Register()
{{
{0}
}}
public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer)
{{
ushort count = 0;
ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
count += 2;
ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
count += 2;
Action<PacketSession, ArraySegment<byte>> action = null;
if (_onRecv.TryGetValue(id, out action))
action.Invoke(session, buffer);
}}
void MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new()
{{
T packet = new T();
packet.Read(buffer);
Action<PacketSession, IPacket> action = null;
if (_handler.TryGetValue(packet.Protocol, out action))
action.Invoke(session, packet);
}}
}}
";
// ...
}
}
- 위의 결과로 GameRoom 안에 존재하는 10명의 유저들에게 채팅 패킷을 뿌려주고 있다는 것을 알 수 있다.
~> 그러나 위와 같은 방식을 MMORPG에 도입할 경우 속도가 상당히 느려질 수 있다.
(유저의 수가 증가할수록, 패킷을 뿌리는 양이 많아지므로)
- 위의 Thread들은 모두 Broadcast의 lock 부분에서 대기중이다.
~> 이는 당연한 결과이다. 왜냐하면 Thread.Sleep(250) 을 통해 0.25초에 1번 동작하도록 설정하였으므로 만약 100명의
유저가 있다고 가정하면 100 * 100 = 10,000번이므로 1초에 40,000번 동작하고 있기 때문이다. (250 * 4 = 1000 = 1초)
이에 lock 부분에 동시다발적으로 수많은 Thread들이 들어오지만 lock 때문에 1번에 1개의 Thread만 처리할 수 있다.
~> 따라서 위의 수많은 작업들이 밀리게 되면서 Thread를 관리하는 입장에서는 Thread를 보냈으나 일 처리가 완료되지
않았기 때문에 다시 Thread를 보내고 있는 상황이 발생한다. 이에 Thread가 계속해서 쌓이게 되는 것이다.
- 이러한 문제가 발생하는 이유는 Recv를 하자마자, lock을 통해 패킷을 전송하였기 때문이다.
~> 해결 방법으로는 하나의 Thread만 Queue에 쌓여있는 일감을 처리하고, 다른 Thread들의 일감은 Queue에 담아두는
것이다. (이를 Job 또는 Task라고 하며, 중요한 것은 패킷을 저장하고 하나의 Thread에서 처리하는 것)
# Command 패턴
- 다음 시간에 만들어볼 Job 또는 Task를 관리하는 Queue가 전형적인 Command 패턴의 예제이다.
- 손님을 대리하는 ClientSession이 직원인 Thread에게 주문을 한다.
- 만약 직원이 서빙, 요리, 계산을 전부 도맡아 할 경우 주문을 받자마자 주방에 달려가 요리를 바로 시작할 것이다.
~> 지금까지 구현된 코드가 위와 비슷하다.
- 만약 주방의 크기가 너무 작아 동시에 1명만 요리가 가능한 경우 주문을 받은 직원들은 주방 앞에서 자신의 요리 차례가
올때까지 계속해서 기다리게 된다.
- 모든 직원들이 자신의 요리 차례를 기다리고 있기 때문에 주문을 받을 직원이 부족한 경우 식당은 직원을 더 고용한다.
~> 지금까지 구현된 코드의 결과가 위와 비슷하다.
- 위와 같이 1명의 직원이 서빙, 요리, 계산을 모두 담당하는 것이 아닌 직원들 각각이 업무를 분담받도록 한다.
public interface Command
{
public void execute();
}
ComputerOnCommand Class
public class ComputerOnCommand implements Command
{
private Computer computer;
public ComputerOnCommand(Computer computer)
{
this.computer = computer;
}
@Override
public void execute()
{
computer.turnOn();
}
}
ComputerOffCommand Class
public class ComputerOffCommand implements Command
{
private Computer computer;
public ComputerOffCommand(Computer computer)
{
this.computer = computer;
}
@Override
public void execute()
{
computer.turnOff();
}
}
Computer Class
public class Computer
{
public void Computer() {}
public void turnOn()
{
System.out.println("컴퓨터 전원 켜짐");
}
public void turnOff()
{
System.out.println("컴퓨터 전원 꺼짐");
}
}
Button Class
public class Button
{
private Command command;
public Button(Command command)
{
this.command = command;
}
public void setCommand(Command command)
{
this.command = command;
}
public void pressButton()
{
this.command.execute();
}
}
Main 메소드
public static void main(String[] args)
{
Computer computer = new Computer(); //컴퓨터는 Receiver
//컴퓨터 객체 생성
ComputerOnCommand computerOnCmd = new ComputerOnCommand(computer);
ComputerOffCommand computerOffCmd = new ComputerOffCommand(computer);
Button btn = new Button(computerOnCmd); //버튼이 Invoker 역할
btn.pressButton();
btn.setCommand(computerOffCmd);
btn.pressButton();
}
# JobQueue #1
- 지난 시간 학습한 Comman 패턴을 활용하여 JobQueue를 구현할 예정이다.
~> Queue에 쌓인 일감을 처리하는 Thread는 Push시 _jobQueue에 처음으로 일감을 밀어 넣는 Thread가 담당한다.
ServerCore에 JobQueue Class 생성
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ServerCore
{
public interface IJobQueue
{
void Push(Action job);
}
public class JobQueue : IJobQueue // IJobQueue를 상속 받는다.
{
Queue<Action> _jobQueue = new Queue<Action>(); // 일감 목록을 담는 곳
object _lock = new object(); // MultiThread 환경을 위한 lock 선언
bool _flush = false; // Queue에 쌓인 일감들을 본인이 처리할 것인지 아닌지
public void Push(Action job) // _jobQueue에 일감을 밀어 넣기 위한 함수
{
bool flush = false; // MultiThread 환경에서 단 1개의 Thread만 일감 처리를 담당하도록
lock (_lock)
{
_jobQueue.Enqueue(job);
if (_flush == false) // _flush가 false인 경우 Queue에 쌓인 일감들을 본인이 처리
flush = _flush = true;
}
if (flush)
Flush();
}
void Flush()
{
while (true)
{
Action action = Pop();
if (action == null)
return;
action.Invoke();
}
}
Action Pop() // 일감 처리를 위해 _jobQueue에서 일감을 꺼내기 위한 함수
{
lock (_lock)
{
if (_jobQueue.Count == 0)
{
_flush = false;
return null;
}
return _jobQueue.Dequeue();
}
}
}
}
GameRoom Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
namespace Server
{
class GameRoom : IJobQueue // IJobQueue를 상속 받는다.
{
// ...
JobQueue _jobQueue = new JobQueue();
public void Push(Action job)
{
_jobQueue.Push(job);
}
// ...
}
}
ClientSession Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Server
{
class ClientSession : PacketSession
{
// ...
public override void OnConnected(EndPoint endPoint)
{
// ...
// 바로 실행하는 것이 아닌 일감을 _jobQueue에 담는 것
Program.Room.Push(() => Program.Room.Enter(this));
// Program.Room.Enter(this); // Client가 접속시 방에 입장시킨다. (Program 산하에 static으로 Room을 생성하였기 때문에 다음과 같이 호출)
// ...
}
// ...
public override void OnDisconnected(EndPoint endPoint)
{
// ...
if (Room != null)
{
// 실행 도중 Client 종료시 Null Crash 방지를 위한 것 & 바로 실행하는 것이 아닌 일감을 _jobQueue에 담는 것
GameRoom room = Room;
room.Push(() => room.Leave(this));
//Room.Leave(this);
Room = null;
}
// ...
}
// ...
}
}
PacketHandler Class 수정
using Server;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
class PacketHandler
{
public static void C_ChatHandler(PacketSession session, IPacket packet)
{
// ...
// 실행 도중 Client 종료시 Null Crash 방지를 위한 것 & 바로 실행하는 것이 아닌 일감을 _jobQueue에 담는 것
GameRoom room = clientSession.Room;
room.Push(() => room.Broadcast(clientSession, chatPacket.chat));
// clientSession.Room.Broadcast(clientSession, chatPacket.chat); // 현재 clientSession이 접속중인 방에 존재하는 모두에게 채팅 메시지를 뿌린다.
}
}
# JobQueue #2
- 지난 시간에 구현한 JobQueue와 똑같이 동작하지만 수동적으로 Task를 만들어주는 방법에 대해 알아볼 예정이다.
~> 람다식 개념이 등장한지 얼마 되지 않아 현업과 같은 실무에서는 수동적으로 일 처리가 필요한 함수들을 구현해서
처리하는 형식이 자주 사용된다.
~> 그러나 위와 같이 수동적인 방법은 일 처리가 필요한 함수를 모두 구현해야 한다는 단점이 존재한다.
Server에 TaskQueue Class 생성 (_queue에 쌓인 일감 처리는 지난 시간의 Flush() 와 같은 메소드에서 처리하도록)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Server
{
interface ITask
{
void Execute();
}
// 일 처리가 필요한 함수에 맞춰 클래스를 생성
class BroadcastTask : ITask
{
// 함수 실행시 필요한 변수들을 선언
GameRoom _room;
ClientSession _session;
string _chat;
// 생성자를 통한 변수 초기화
BroadcastTask(GameRoom room, ClientSession session, string chat)
{
_room = room;
_session = session;
_chat = chat;
}
public void Execute()
{
_room.Broadcast(_session, _chat);
}
}
class TaskQueue
{
Queue<ITask> _queue = new Queue<ITask>();
}
}
부하 Test를 위한 Listener Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
public class Listener
{
Socket _listenSocket;
Func<Session> _sessionFactory;
// 문지기 수를 10명으로 증가, 최대 대기수를 100명으로 증원
public void Init(IPEndPoint endPoint, Func<Session> sessionFactory, int register = 10, int backlog = 100)
{
// 문지기 고용
_listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
_sessionFactory += sessionFactory;
// 문지기 교육
_listenSocket.Bind(endPoint);
// 영업 시작
// backlog : 최대 대기 수
_listenSocket.Listen(backlog);
for (int i = 0; i < register; i++)
{
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted);
RegisterAccept(args);
}
}
// ...
}
}
- 만약 Client 접속 인원을 500명으로 늘린 뒤에 실행할 경우 메모리가 계속해서 상승하는 것을 볼 수 있다.
~> 이는 수많은 작업들이 밀리게 되면서Thread를 관리하는 입장에서는 Thread를 보냈으나 일 처리가 완료되지 않았기
때문에 ThreadPool에서 새로운 Thread를 뽑아 보내고 있기 때문에 메모리가 계속해서 상승하는 것이다.
- 가장 심한 부하를 유발하는 곳은 Broadcast의 foreach문 내의 Send이다.
~> 왜냐하면 Thread.Sleep(250) 을 통해 0.25초에 1번 동작하도록 설정하였으므로500명의 유저가 있다고 가정하면
500 * 500 = 250,000번이므로 1초에 1,000,000번 동작하고 있기 때문이다.
~> 이는 N^2의 시간 복잡도를 가진다.
~> 해결 방법으로는 패킷 요청이 올때마다 바로 Send 하는 것이 아닌 패킷을 모은 뒤 추후에 한번에 보내는 것이다.
- 패킷을 모아 보내는 것은 Engine 쪽에서도 처리할 수 있고 Content 쪽에서도 처리할 수 있다.
~> 만약 Engine 쪽에서 처리하고자 할 경우 Session의 Send() 메소드에서 처리가 가능하다.
(어느정도 쌓아둔 뒤 일정 조건에 따라 보내는 구조로 변경)
~> 그러나 이번 시간에는 Content 쪽에서 패킷 모아 보내기를 구현할 예정이다.
~> 패킷이 불규칙하게 전송될 경우 RecvBuffer의 크기를 늘려주면 된다.
Session Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
// ...
public abstract class Session
{
// ...
// List<ArraySegment<byte>> 를 매개변수로 받아 List에 담긴 패킷들을 전부 처리하는 Send 메소드 추가
public void Send(List<ArraySegment<byte>> sendBuffList)
{
if (sendBuffList.Count == 0)
return;
lock (_lock)
{
foreach (ArraySegment<byte> sendBuff in sendBuffList)
_sendQueue.Enqueue(sendBuff);
if (_pendingList.Count == 0)
RegisterSend();
}
}
// 기존의 Send 메소드
public void Send(ArraySegment<byte> sendBuff)
{
lock (_lock)
{
_sendQueue.Enqueue(sendBuff);
if (_pendingList.Count == 0)
RegisterSend();
}
}
// ...
}
}
GameRoom Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
namespace Server
{
class GameRoom : IJobQueue
{
// ...
List<ArraySegment<byte>> _pendingList = new List<ArraySegment<byte>>(); // 패킷을 모으기 위한 List
// ...
public void Flush() // 모아둔 패킷을 처리
{
foreach (ClientSession s in _sessions)
s.Send(_pendingList);
Console.WriteLine($"Flushed {_pendingList.Count} items");
_pendingList.Clear();
}
// ..
}
}
Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;
namespace Server
{
class Program
{
// ...
static void Main(string[] args)
{
// ...
while (true)
{
Room.Push(() => Room.Flush());
Thread.Sleep(250);
}
}
}
}
# JobTimer
- GameRoom 뿐만이 아닌 다양한 Room들이 추가될 경우 각각의 룸들은 서로 다른 대기시간을 가지며 실행된다.
~> 첫번째로는 Tick을 이용하는 방법이 있다.
~> 두번째로는 PriorityQueue를 이용하는 방법이 있다.
Tick을 통해 처리하기 위한 Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;
namespace Server
{
class Program
{
// ...
static void Main(string[] args)
{
// ...
int roomTick = 0;
// room이 추가될때마다 Tick 변수도 추가된다.
while (true)
{
int now = System.Environment.TickCount;
if (roomTick < now) // 그러나 이와 같은 방법은 불필요한 if문 체크가 반복된다.
{
Room.Push(() => Room.Flush());
roomTick = now + 250;
}
// room이 추가될때마다 if문도 추가된다.
}
}
}
}
ServerCore에 PriorityQueue Class 생성
using System;
using System.Collections.Generic;
using System.Text;
namespace ServerCore
{
public class PriorityQueue<T> where T : IComparable<T>
{
List<T> _heap = new List<T>();
public int Count { get { return _heap.Count; } }
// 0 (logN)
public void Push(T data)
{
// 힙의 맨 끝에 새로운 데이터를 삽입한다.
_heap.Add(data);
int now = _heap.Count - 1;
// 도장깨기를 시작
while (now > 0)
{
// 도장깨기를 시도
int next = (now - 1) / 2;
if (_heap[now].CompareTo(_heap[next]) < 0) // 대상값이 비교값과 같은 경우 0, 작은 경우 -1, 큰 경우 1 ( 대상값.CompareTo(비교값) )
break; // 실패
// 두 값을 교체한다.
T temp = _heap[now];
_heap[now] = _heap[next];
_heap[next] = temp;
// 검사 위치를 이동한다.
now = next;
}
}
// 0 (logN)
public T Pop()
{
// 반환할 데이터를 따로 저장
T ret = _heap[0];
// 마지막 데이터를 루트로 이동한다.
int lastIndex = _heap.Count - 1;
_heap[0] = _heap[lastIndex];
_heap.RemoveAt(lastIndex);
lastIndex--;
// 역으로 내려가는 도장깨기 시작
int now = 0;
while (true)
{
int left = 2 * now + 1;
int right = 2 * now + 2;
int next = now;
// 왼쪽값이 현재값보다 크면, 왼쪽으로 이동
if (left <= lastIndex && _heap[next].CompareTo(_heap[left]) < 0)
next = left;
// 오른쪽값이 현재값(왼쪽 이동 포함)보다 크면, 오른쪽으로 이동
if (right <= lastIndex && _heap[next].CompareTo(_heap[right]) < 0)
next = right;
// 왼쪽/오른쪽 모두 현재값보다 작으면 종료
if (next == now)
break;
// 두 값을 교체한다.
T temp = _heap[now];
_heap[now] = _heap[next];
_heap[next] = temp;
// 검사 위치를 이동한다.
now = next;
}
return ret;
}
public T Peek() // 일감 내용 확인을 위한 함수
{
if (_heap.Count == 0)
return default(T);
return _heap[0];
}
}
}
Server에 JobTimer Class 생성
using System;
using System.Collections.Generic;
using System.Text;
using ServerCore;
namespace Server
{
struct JobTimerElem : IComparable<JobTimerElem> // 하나의 일감 단위
{
public int execTick; // 실행 시간
public Action action; // 일감 (즉, Job)
// IComparable 인터페이스의 필수 구성 요소인 CompareTo()
public int CompareTo(JobTimerElem other)
{
// 실행 시간이 적을수록 먼저 튀어나오도록 (즉, 우선순위가 높은 항목)
return other.execTick - execTick;
}
}
class JobTimer // Job을 예약할 수 있는 시스템
{
// 우선순위 큐는 대소관계 연산 처리 속도가 상당히 빠르다.
PriorityQueue<JobTimerElem> _pq = new PriorityQueue<JobTimerElem>();
object _lock = new object(); // MultiThread 환경을 위한 lock 선언
public static JobTimer Instance { get; } = new JobTimer();
public void Push(Action action, int tickAfter = 0) // 예약하고자 하는 일감, 몇 Tick 후에 실행해야하는지 (입력 받지 못한 경우 바로 실행하도록 0으로 설정)
{
JobTimerElem job;
job.execTick = System.Environment.TickCount + tickAfter;
job.action = action;
lock (_lock)
{
_pq.Push(job);
}
}
public void Flush() // 일감 처리
{
while (true)
{
int now = System.Environment.TickCount;
JobTimerElem job;
lock (_lock)
{
if (_pq.Count == 0) // 일감이 없는 경우
break; // while문을 나간다는 의미
job = _pq.Peek(); // 꺼내지 않고 일감 내용만 확인하는 것
if (job.execTick > now) // 현재 시간보다 실행 시간이 많이 남은 경우
break;
_pq.Pop();
}
// 일감을 실행시킨다.
job.action.Invoke();
}
}
}
}
Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;
namespace Server
{
class Program
{
// ...
static void FlushRoom()
{
Room.Push(() => Room.Flush());
JobTimer.Instance.Push(FlushRoom, 250);
}
static void Main(string[] args)
{
// ...
JobTimer.Instance.Push(FlushRoom);
while (true)
{
JobTimer.Instance.Flush();
}
}
}
}
Unity Project에 NetworkManager Script 생성후 빈 객체에 Component로 추가
using DummyClient;
using ServerCore;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using UnityEngine;
public class NetworkManager : MonoBehaviour
{
ServerSession _session = new ServerSession();
void Start()
{
// DNS (Domain Name System)
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0];
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);
Connector connector = new Connector();
connector.Connect(endPoint, () => { return _session; }, 1);
}
void Update()
{
}
}
통신이 원활하게 이루어지는지 확인하기 위해 Unity Project의 PacketHandler Script 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
class PacketHandler
{
public static void S_ChatHandler(PacketSession session, IPacket packet)
{
S_Chat chatPacket = packet as S_Chat;
ServerSession serverSession = session as ServerSession;
if (chatPacket.playerId == 1)
Debug.Log(chatPacket.chat);
//if (chatPacket.playerId == 1)
//Console.WriteLine(chatPacket.chat);
}
}
- Test를 위해Server솔루션 프로젝트를 실행시켜 Server와 Client를 구동시킨 뒤, Unity의 Play 버튼을 누르면
Console 창에 Log가 뜨는 것을 확인할 수 있다.
# 유니티 연동 #2
- 이번 시간에는 단순히 Log만 띄우는 것이 아닌 실질적인 액션을 취하도록 만들 예정이다.
~> 우선 Unity에서 [ Hierarchy ] - [ 오른쪽 마우스 ] - [ 3D Object ] - [ Cylinder ] 를 통해 3D 객체 생성 후 이름을
Player로 설정해준다.
Unity Project의 PacketHandler Script 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
class PacketHandler
{
public static void S_ChatHandler(PacketSession session, IPacket packet)
{
S_Chat chatPacket = packet as S_Chat;
ServerSession serverSession = session as ServerSession;
if (chatPacket.playerId == 1)
{
Debug.Log(chatPacket.chat);
// Unity 내의 객체를 찾는 Logic 추가
GameObject go = GameObject.Find("Player");
if (go == null)
Debug.Log("Player not found");
else
Debug.Log("Player found");
}
}
}
- 위의 추가한 Logic은 정상적으로 실행되지 않는다.
~> 기존의 우리가 작성한 Server 솔루션의 Logic은 비동기로 네트워크 통신을 하고 있다.
~> 따라서 Unity를 구동하는 Main Thread에서 네트워크 패킷을 실행하는 것이 아닌, Thread Pool에서 Thread를 꺼내와
실행하고 있는 것이 문제가 된다.
~> Unity는 다른 Thread에서 게임과 관련된 부분을 접근하여 실행하는 것을 원천적으로 차단해 두었기 때문에
정상적으로 실행되지 않는 것이다.
~> 해결 방법으로는 PacketHandler Class가 Main Thread에서 실행되도록 만들면 된다.
(S_ChatHandler에서 Logic을 처리하는 것이 아닌 Queue에 일감을 등록후, 처리하는 Logic을 구분 지어 사용)
Unity Project의 GenPacket Script 수정
using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using ServerCore;
// ...
public interface IPacket // 접근한정자 public 추가
{
ushort Protocol { get; }
void Read(ArraySegment<byte> segment);
ArraySegment<byte> Write();
}
// ...
Unity Project에 NetworkManager Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// Main Thread와 Background Thread (즉, 네트워크를 처리하는 애들끼리) 는 PacketQueue라는 통로를 통해 소통한다.
// ~> Background Thread는 Pakcet을 Push하여 밀어 넣고, Main Thread에서는 Packet을 Pop하여 처리한다.
public class PacketQueue // Component로 사용할 건 X ~> MonoBehaviour 상속 X
{
public static PacketQueue Instance { get; } = new PacketQueue();
Queue<IPacket> _packetQueue = new Queue<IPacket>();
object _lock = new object();
public void Push(IPacket packet)
{
lock (_lock)
{
_packetQueue.Enqueue(packet);
}
}
public IPacket Pop()
{
lock ( _lock)
{
if (_packetQueue.Count == 0)
return null;
return _packetQueue.Dequeue();
}
}
}
일감 등록을 위한 Unity Project의 ClientPacketManager Script 수정
using ServerCore;
using System;
using System.Collections.Generic;
class PacketManager
{
#region Singleton
static PacketManager _instance = new PacketManager();
public static PacketManager Instance { get { return _instance; } }
#endregion
PacketManager()
{
Register();
}
Dictionary<ushort, Func<PacketSession, ArraySegment<byte>, IPacket>> _makeFunc = new Dictionary<ushort, Func<PacketSession, ArraySegment<byte>, IPacket>>();
Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>();
public void Register()
{
_makeFunc.Add((ushort)PacketID.S_Chat, MakePacket<S_Chat>);
_handler.Add((ushort)PacketID.S_Chat, PacketHandler.S_ChatHandler);
}
// Action<PacketSession, IPacket> Type의 매개변수인 onRecvCallback 을 추가로 입력 받는다.
public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer, Action<PacketSession, IPacket> onRecvCallback = null)
{
ushort count = 0;
ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset);
count += 2;
ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
count += 2;
Func<PacketSession, ArraySegment<byte>, IPacket> func = null;
if (_makeFunc.TryGetValue(id, out func))
{
IPacket packet = func.Invoke(session, buffer);
if (onRecvCallback != null)
onRecvCallback.Invoke(session, packet);
else
HandlePacket(session, packet);
}
}
T MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new()
{
T pkt = new T();
pkt.Read(buffer);
return pkt;
}
public void HandlePacket(PacketSession session, IPacket packet)
{
Action<PacketSession, IPacket> action = null;
if (_handler.TryGetValue(packet.Protocol, out action))
action.Invoke(session, packet);
}
}
일감 등록을 위한 Unity Project의 ServerSession Script 수정
using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using ServerCore;
namespace DummyClient
{
class ServerSession : PacketSession
{
// ...
// Action<PacketSession, IPacket> Type의 매개변수인 onRecvCallback 을 추가로 입력
public override void OnRecvPacket(ArraySegment<byte> buffer)
{
PacketManager.Instance.OnRecvPacket(this, buffer, (s, p) => PacketQueue.Instance.Push(p));
}
// ...
}
}
일감 처리를 위한 Unity Project의 NetworkManager Script 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using UnityEngine;
public class NetworkManager : MonoBehaviour
{
ServerSession _session = new ServerSession();
void Start()
{
// DNS (Domain Name System)
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0];
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);
Connector connector = new Connector();
connector.Connect(endPoint,
() => { return _session; }, 1);
StartCoroutine("CoSendPacket");
}
void Update()
{
// 일감 처리
IPacket packet = PacketQueue.Instance.Pop();
if (packet != null)
{
PacketManager.Instance.HandlePacket(_session, packet);
}
}
// 3초마다 패킷을 보내도록 (즉, DummyClient의 역할)
IEnumerator CoSendPacket()
{
while (true)
{
yield return new WaitForSeconds(3.0f);
C_Chat chatPacket = new C_Chat();
chatPacket.chat = "Hello Unity !";
ArraySegment<byte> segment = chatPacket.Write();
_session.Send(segment);
}
}
}
PacketFormat Class 수정
using System;
using System.Collections.Generic;
using System.Text;
namespace PacketGenerator
{
class PacketFormat
{
// {0} 패킷 등록
public static string managerFormat =
@"using ServerCore;
using System;
using System.Collections.Generic;
public class PacketManager
{{
#region Singleton
static PacketManager _instance = new PacketManager();
public static PacketManager Instance {{ get {{ return _instance; }} }}
#endregion
PacketManager()
{{
Register();
}}
Dictionary<ushort, Func<PacketSession, ArraySegment<byte>, IPacket>> _makeFunc = new Dictionary<ushort, Func<PacketSession, ArraySegment<byte>, IPacket>>();
Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>();
public void Register()
{{
{0}
}}
public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer, Action<PacketSession, IPacket> onRecvCallback = null)
{{
ushort count = 0;
ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset);
count += 2;
ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
count += 2;
Func<PacketSession, ArraySegment<byte>, IPacket> func = null;
if (_makeFunc.TryGetValue(id, out func))
{{
IPacket packet = func.Invoke(session, buffer);
if (onRecvCallback != null)
onRecvCallback.Invoke(session, packet);
else
HandlePacket(session, packet);
}}
}}
T MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new()
{{
T pkt = new T();
pkt.Read(buffer);
return pkt;
}}
public void HandlePacket(PacketSession session, IPacket packet)
{{
Action<PacketSession, IPacket> action = null;
if (_handler.TryGetValue(packet.Protocol, out action))
action.Invoke(session, packet);
}}
}}";
// {0} 패킷 이름
public static string managerRegisterFormat =
@" _makeFunc.Add((ushort)PacketID.{0}, MakePacket<{0}>);
_handler.Add((ushort)PacketID.{0}, PacketHandler.{0}Handler);";
// {0} 패킷 이름/번호 목록
// {1} 패킷 목록
public static string fileFormat =
@"using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using ServerCore;
public enum PacketID
{{
{0}
}}
public interface IPacket
{{
ushort Protocol {{ get; }}
void Read(ArraySegment<byte> segment);
ArraySegment<byte> Write();
}}
{1}
";
// ...
}
}
# 유니티 연동 #3
- 이번 시간과 다음 시간을 통해 Server에서의 Player 생성 및 움직임을 구현 예정이다.
~> 우선 Server Logic 수정 후, DummyClient Logic을 수정할 것이다.
using System;
using System.Collections.Generic;
using System.Text;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;
using System.Net;
namespace Server
{
class ClientSession : PacketSession
{
public int SessionId { get; set; }
public GameRoom Room { get; set; }
public float PosX { get; set; } // x 좌표 값 저장을 위해 추가
public float PosY { get; set; } // y 좌표 값 저장을 위해 추가
public float PosZ { get; set; } // z 좌표 값 저장을 위해 추가
// ...
}
}
GameRoom Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
namespace Server
{
class GameRoom : IJobQueue
{
// ...
public void Broadcast(ArraySegment<byte> segment)
{
_pendingList.Add(segment);
}
public void Enter(ClientSession session)
{
// 플레이어 추가
_sessions.Add(session);
session.Room = this;
// 신입생한테 모든 플레이어 목록 전송
S_PlayerList players = new S_PlayerList();
foreach (ClientSession s in _sessions)
{
players.players.Add(new S_PlayerList.Player()
{
isSelf = (s == session),
playerId = s.SessionId,
posX = s.PosX,
posY = s.PosY,
posZ = s.PosZ
});
}
session.Send(players.Write());
// 신입생 입장을 모두에게 알린다
S_BroadcastEnterGame enter = new S_BroadcastEnterGame();
enter.playerId = session.SessionId;
enter.posX = 0;
enter.posY = 0;
enter.posZ = 0;
Broadcast(enter.Write());
}
public void Leave(ClientSession session)
{
// 플레이어 제거
_sessions.Remove(session);
// 플레이어 퇴장을 모두에게 알린다
S_BroadcastLeaveGame leave = new S_BroadcastLeaveGame();
leave.playerId = session.SessionId;
Broadcast(leave.Write());
}
public void Move(ClientSession session, C_Move packet)
{
// 좌표를 바꿔주고
session.PosX = packet.posX;
session.PosY = packet.posY;
session.PosZ = packet.posZ;
// 모두에게 알린다
S_BroadcastMove move = new S_BroadcastMove();
move.playerId = session.SessionId;
move.posX = session.PosX;
move.posY = session.PosY;
move.posZ = session.PosZ;
Broadcast(move.Write());
}
}
}
Server의 PacketHandler 수정
using Server;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
class PacketHandler
{
public static void C_LeaveGameHandler(PacketSession session, IPacket packet)
{
C_LeaveGame chatPacket = packet as C_LeaveGame;
ClientSession clientSession = session as ClientSession;
if (clientSession.Room == null)
return;
GameRoom room = clientSession.Room;
room.Push(() => room.Leave(clientSession));
}
public static void C_MoveHandler(PacketSession session, IPacket packet)
{
C_Move movePacket = packet as C_Move;
ClientSession clientSession = session as ClientSession;
if (clientSession.Room == null)
return;
GameRoom room = clientSession.Room;
room.Push(() => room.Move(clientSession, movePacket));
}
}
DummyClient의 PacketHandler 수정 (빌드가 통과할 수 있도록 함수만 만들어줄 뿐, 실질적인 작업은 유니티 내부에서 처리)
using DummyClient;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
class PacketHandler
{
public static void S_BroadcastEnterGameHandler(PacketSession session, IPacket packet)
{
S_BroadcastEnterGame pkt = packet as S_BroadcastEnterGame;
ServerSession serverSession = session as ServerSession;
}
public static void S_BroadcastLeaveGameHandler(PacketSession session, IPacket packet)
{
S_BroadcastLeaveGame pkt = packet as S_BroadcastLeaveGame;
ServerSession serverSession = session as ServerSession;
}
public static void S_PlayerListHandler(PacketSession session, IPacket packet)
{
S_PlayerList pkt = packet as S_PlayerList;
ServerSession serverSession = session as ServerSession;
}
public static void S_BroadcastMoveHandler(PacketSession session, IPacket packet)
{
S_BroadcastMove pkt = packet as S_BroadcastMove;
ServerSession serverSession = session as ServerSession;
}
}
DummyClient의 SessionManager 수정
using System;
using System.Collections.Generic;
using System.Text;
namespace DummyClient
{
class SessionManager
{
// ...
Random _rand = new Random();
public void SendForEach()
{
lock (_lock)
{
// 채팅 패킷이 아닌 이동 패킷을 보내도록 수정
foreach (ServerSession session in _sessions)
{
C_Move movePacket = new C_Move();
movePacket.posX = _rand.Next(-50, 50);
movePacket.posY = 0;
movePacket.posZ = _rand.Next(-50, 50);
session.Send(movePacket.Write());
}
}
}
// ...
}
}
# 유니티 연동 #4
- 지난 시간에 이어 Server에서의 Player 생성 및 움직임을 마저 구현할 예정이다.
Unity Project의 PacketQueue Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PacketQueue
{
// ...
public List<IPacket> PopAll()
{
List<IPacket> list = new List<IPacket>();
lock (_lock)
{
while (_packetQueue.Count > 0)
list.Add(_packetQueue.Dequeue());
}
return list;
}
}
Unity Project의 NetworkManager Script 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using UnityEngine;
public class NetworkManager : MonoBehaviour
{
// ...
public void Send(ArraySegment<byte> sendBuff)
{
_session.Send(sendBuff);
}
// ...
void Update()
{
// 일감 처리
// 프레임마다 1개의 일감만을 처리하는 것이 아닌, 프레임마다 모든 일감하도록 수정
List<IPacket> list = PacketQueue.Instance.PopAll(); // 즉, Pop()이 아닌 PopAll()을 실행하도록 수정
foreach (IPacket packet in list)
PacketManager.Instance.HandlePacket(_session, packet);
}
}
Unity Project에 Player Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Player : MonoBehaviour
{
public int PlayerId { get; set; }
}
Unity Project에 MyPlayer Script 생성
using ServerCore;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MyPlayer : Player
{
NetworkManager _network;
void Start()
{
StartCoroutine("CoSendPacket");
_network = GameObject.Find("NetworkManager").GetComponent<NetworkManager>();
}
void Update()
{
}
IEnumerator CoSendPacket()
{
while (true)
{
yield return new WaitForSeconds(0.25f);
C_Move movePacket = new C_Move();
movePacket.posX = UnityEngine.Random.Range(-50, 50);
movePacket.posY = 0;
movePacket.posZ = UnityEngine.Random.Range(-50, 50);
_network.Send(movePacket.Write());
}
}
}
Unity Project에 PlayerManager Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerManager // 기생하는 것이 아닌 데이터만 들고 있도록 MonoBehaviour 상속 X
{
MyPlayer _myPlayer;
Dictionary<int, Player> _players = new Dictionary<int, Player>();
public static PlayerManager Instance { get; } = new PlayerManager();
public void Add(S_PlayerList packet)
{
Object obj = Resources.Load("Player");
foreach (S_PlayerList.Player p in packet.players)
{
GameObject go = Object.Instantiate(obj) as GameObject;
if (p.isSelf)
{
MyPlayer myPlayer = go.AddComponent<MyPlayer>();
myPlayer.PlayerId = p.playerId;
myPlayer.transform.position = new Vector3(p.posX, p.posY, p.posZ);
_myPlayer = myPlayer;
}
else
{
Player player = go.AddComponent<Player>();
player.PlayerId = p.playerId;
player.transform.position = new Vector3(p.posX, p.posY, p.posZ);
_players.Add(p.playerId, player);
}
}
}
public void Move(S_BroadcastMove packet)
{
if (_myPlayer.PlayerId == packet.playerId)
{
_myPlayer.transform.position = new Vector3(packet.posX, packet.posY, packet.posZ);
}
else
{
Player player = null;
if (_players.TryGetValue(packet.playerId, out player))
{
player.transform.position = new Vector3(packet.posX, packet.posY, packet.posZ);
}
}
}
public void EnterGame(S_BroadcastEnterGame packet)
{
if (packet.playerId == _myPlayer.PlayerId)
return;
Object obj = Resources.Load("Player");
GameObject go = Object.Instantiate(obj) as GameObject;
Player player = go.AddComponent<Player>();
player.transform.position = new Vector3(packet.posX, packet.posY, packet.posZ);
_players.Add(packet.playerId, player);
}
public void LeaveGame(S_BroadcastLeaveGame packet)
{
if (_myPlayer.PlayerId == packet.playerId)
{
GameObject.Destroy(_myPlayer.gameObject);
_myPlayer = null;
}
else
{
Player player = null;
if (_players.TryGetValue(packet.playerId, out player))
{
GameObject.Destroy(player.gameObject);
_players.Remove(packet.playerId);
}
}
}
}
Unity Project의 PacketHandler Script 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
class PacketHandler
{
public static void S_BroadcastEnterGameHandler(PacketSession session, IPacket packet)
{
S_BroadcastEnterGame pkt = packet as S_BroadcastEnterGame;
ServerSession serverSession = session as ServerSession;
PlayerManager.Instance.EnterGame(pkt);
}
public static void S_BroadcastLeaveGameHandler(PacketSession session, IPacket packet)
{
S_BroadcastLeaveGame pkt = packet as S_BroadcastLeaveGame;
ServerSession serverSession = session as ServerSession;
PlayerManager.Instance.LeaveGame(pkt);
}
public static void S_PlayerListHandler(PacketSession session, IPacket packet)
{
S_PlayerList pkt = packet as S_PlayerList;
ServerSession serverSession = session as ServerSession;
PlayerManager.Instance.Add(pkt);
}
public static void S_BroadcastMoveHandler(PacketSession session, IPacket packet)
{
S_BroadcastMove pkt = packet as S_BroadcastMove;
ServerSession serverSession = session as ServerSession;
PlayerManager.Instance.Move(pkt);
}
}