상세 컨텐츠

본문 제목

RyuLib.Game for Android 시작하기

프로그래밍/Android

by ryujt 2010. 12. 22. 03:39

본문

RyuLib.Game for Android의 프로젝트 명은 "게임엔진이라고 하기에는 심하게 쪽팔리지만"이며, 줄여서 "게쪽"입니다.  현재는 ver 0.1 beta 상황입니다.  게쪽은 수준 높은 게임을 목표로 하기보다, 입문자들이 비교적 쉽게 게임 실무에 익숙해 질 수 있도록 하는 것이 목표입니다.

[그림 1] Class Diagram

[그림 1]은 게쪽의 가장 중요한 클래스의 Class Diagram 입니다.  쉽게 설명하기 위해서 상당히 단순화 하였습니다.  디자인 패턴에 익숙하신 분들은 오른쪽이 컴포지트(Composite) 패턴으로 이루어 져 있다는 것을 아실 수 있을 것 입니다.

GamePlatform 클래스는 SurfaceView를 상속받아 확장하였습니다.  화면 그리기에 대한 기능을 제공하며, 게임 컨트롤을 관리합니다.

GameControlBase 클래스는 게임 컨트롤의 추상 클래스입니다.  게임 컨트롤이란, 게임에 사용되는 비행기, 미사일 등의 객체를 뜻 합니다.  애니메이션 효과 및 충돌 테스트 등의 기능이 내장되어 있습니다.

그런데, GameControlBase는 GameControlGroup과 GameControl로 나눠지고 있습니다.  컴포지트 패턴이 무엇인지 모르시는 분들은 GameControlGroup을 폴더라고 생각하시고, GameControl을 파일이라고 생각하시면 됩니다.  

즉, GameControlGroup은 GameControlGroup과 GameControl을 포함 할 수 있다는 뜻 입니다.  예를 들어 게임 케릭터가, [그림 2]와 같이, 하나의 객체가 아닌 여러 개의 객체로 구성되어 있다고 합시다.  케릭터와 아이템 주머니는 다른 컨트롤을 포함해야 하므로 GameControlGroup을 상속 받아서 확장하고, 칼, 방패, 아이템1, 아이템2는 GameControl을 상속 받아서 확장하면 됩니다.

이렇게 GameControlGroup과 GameControl로 나눠서 작업하는 이유는, 관리해야 할 객체가 많아질 때, 분류를 통해서  보다 효율적으로 처리하기 위해서 입니다.  MP3 파일 수 천 개를 하나의 폴더에 몽땅 저장해 놓은 후, 원하는 가수의 노래만 찾아야 하는 경우를 생각해보시면, 왜 컴포지트 패턴을 사용했는 지 이해하기 쉬울 것 입니다.

당분간은 GameControlGroup을 사용하지 않고 가벼운 예제를 중심으로 설명을 이어 갈 것 입니다.

[그림 2] 케릭터의 객체 구성



RyuLib 설치

우선 소스는 아래의 사이트에서 다운받으시면 됩니다.  SVN을 사용합니다.

만약 SVN 사용이 익숙하지 않으신 분들께서는 아래의 링크에서 첨부파일을 받아서 사용하시면 됩니다.

외부 라이브러리로 연결해서 사용하는 것이 어려우신 분들은 당분간 프로그램을 작성하실 때, 소ryulib 폴더 전체를 프로그램의 src 폴더 밑에 복사해서 사용하시면 됩니다.  저도 강좌를 진행하는 동안에는 ryulib 폴더를 src 폴더에 복사해서 사용하도록 하겠습니다.




간단한 도형 출력의 예

이해를 돕기 위해서 간단한 도형을 출력하는 예제를 작성해보도록 하겠습니다.  

새로운 프로젝트를 시작하시고, [소스1]과 [소스 2]와 같이 코딩을 마친 후, 실행하면 [그림 3]과 같이 빨간 박스가 화면 좌측 상단에 나타나게 됩니다.  

[그림 3] 프로그램 실행 결과

이때, CPU 사용률을 보면, 별것 아닌 프로그램이 상당히 많은 CPU를 사용하고 있는 것을 발견하게 됩니다.  화면에는 가만히 있는 것처럼 보이지만, 반복해서 빨간 박스를 그리고 있는 것이기 때문입니다.  이처럼, 게쪽은 Direct-X 프로그래밍과 같이, 전체 화면을 계속 갱신하도록 되어 있습니다.  CPU를 효율적으로 사용하는 방법에 대해서는 추후 다루도록 하겠습니다.

[소스 1] Main.java
package app.main;

import ryulib.game.GamePlatform;
import android.app.Activity;
import android.os.Bundle;
import android.view.ViewGroup;
import android.widget.LinearLayout;

public class Main extends Activity {
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        _GamePlatform = new GamePlatform(this);
        _GamePlatform.setLayoutParams(
        		new LinearLayout.LayoutParams(
        				ViewGroup.LayoutParams.FILL_PARENT, 
        				ViewGroup.LayoutParams.FILL_PARENT, 
        				0.0F
        		)
        );
        
        setContentView(_GamePlatform);
        
        _GamePlatform.AddControl(_Box);
    }
    
    private GamePlatform _GamePlatform = null;
    private Box _Box = new Box(null);
    
} 
15: 게쪽의 화면을 담당하는 GamePlatform 생성 합니다.

16-22: GamePlatform을 화면에 꽉차도록 레이아웃 설정을 합니다.

24: setContentView() 메소드를 이용하여 생성된 GamePlatform이 화면에 표시 되도록 합니다.

26: 게임 컨트롤인 Box를 GamePlatform에 추가합니다. GamePlatform은 추가된 모든 컨트롤을 차례대로 반복하면서 화면에 그려줍니다.

[소스 2] Box.java
package app.main;

import ryulib.game.GameControl;
import ryulib.game.GameControlGroup;
import ryulib.game.GamePlatformInfo;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;

public class Box extends GameControl {

	public Box(GameControlGroup gameControlGroup) {
		super(gameControlGroup);
	}
	
	private Rect _rect = new Rect(0, 0, 32, 32);

	@Override
	protected void onDraw(GamePlatformInfo platformInfo) {
		Paint paint = platformInfo.getPaint();
		Canvas canvas = platformInfo.getCanvas();
		
		paint.setARGB(255, 255, 0, 0);
		canvas.drawRect(_rect, paint);
	}
}
Box는 GameControl을 상속 받아서 확장하여 코딩 합니다.  이때, GameControl의 부모 클래스인  GameControlBase의 메소드를 재정의(override)하면서 게쪽이 제공하는 기능을 이용하게 됩니다.

18-25: GameControlBase의 onDraw 메소드를 재정의 하고 있습니다.

20-21: 파라메터로 전달되는 platformInfo 객체에는 GamePlatform이 제공하는 여러 가지 정보가 담겨져 있습니다.  그중에서  Paint와 Canvas 객체를 가져옵니다.  

Paint와 Canvas는, 굳이 레퍼런스를 옮기지 않아도 됩니다.  즉, 아래와 같이 표현해도 됩니다.
platformInfo.getPaint().setARGB(255, 255, 0, 0); 
platformInfo.getCanvas().drawRect(_rect, platformInfo.getPaint());

23: paint의 색상을 빨간색으로 지정합니다.

24: 화면에 지정된 색상으로 박스를 그립니다.  박스의 크기는 16: 라인에서 지정되어 있습니다.



애니메이션 효과

이번에는 애니메이션 효과를 주는 방법을 알아보겠습니다.  우선 Main.java는 [소스 1]과 유사 합니다.

[소스 3] Main.java
package app.main;

import ryulib.game.GamePlatform;
import android.app.Activity;
import android.os.Bundle;
import android.view.ViewGroup;
import android.widget.LinearLayout;

public class Main extends Activity {
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        _GamePlatform = new GamePlatform(this);
        _GamePlatform.setLayoutParams(
        		new LinearLayout.LayoutParams(
        				ViewGroup.LayoutParams.FILL_PARENT, 
        				ViewGroup.LayoutParams.FILL_PARENT, 
        				0.0F
        		)
        );
        
        setContentView(_GamePlatform);
        
        _Asteroid = new Asteroid(this, 100, 0);
        _GamePlatform.AddControl(_Asteroid);
    }
    
    private GamePlatform _GamePlatform = null;
    private Asteroid _Asteroid = null; 
    
}
달라진 부분은 31: 라인에서 Box 대신 Asteroid 클래스로 변경되었습니다.  그리고, 26: 라인에서 객체를 생성하면서 Context와 처음 표시 될 좌표 (x, y)를 넘기고 있습니다.  [소스 3]에서는 (100, 0) 좌표에 표시되도록 하고 있습니다.


[소스 4]
package app.main;

import ryulib.game.GameControl;
import ryulib.game.GamePlatformInfo;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;

public class Asteroid extends GameControl {

	public Asteroid(Context context, int x, int y) {
		super(null);
		
		_X = x;
		_Y = y;
		
		init(context);
	}

	private static final int _ANIMATIONINTERVAL = 100;
	private static final int _SPEED = 100;
	
	private Canvas _Canvas = null;
	private Paint _Paint = null;
	
	private int _X = 0;
	private int _Y = 0;
	private double _DY = 0;
	
    private Bitmap[] _Bitmaps = new Bitmap[12];
    private int _BitmapIndex = 0;
    private long _TickCount = 0;
    
    private void init(Context context) {
        Resources resources = context.getResources();
        
        _Bitmaps[11] = BitmapFactory.decodeResource(resources, R.drawable.asteroid01);
        _Bitmaps[10] = BitmapFactory.decodeResource(resources, R.drawable.asteroid02);
        _Bitmaps[ 9] = BitmapFactory.decodeResource(resources, R.drawable.asteroid03);
        _Bitmaps[ 8] = BitmapFactory.decodeResource(resources, R.drawable.asteroid04);
        _Bitmaps[ 7] = BitmapFactory.decodeResource(resources, R.drawable.asteroid05);
        _Bitmaps[ 6] = BitmapFactory.decodeResource(resources, R.drawable.asteroid06);
        _Bitmaps[ 5] = BitmapFactory.decodeResource(resources, R.drawable.asteroid07);
        _Bitmaps[ 4] = BitmapFactory.decodeResource(resources, R.drawable.asteroid08);
        _Bitmaps[ 3] = BitmapFactory.decodeResource(resources, R.drawable.asteroid09);
        _Bitmaps[ 2] = BitmapFactory.decodeResource(resources, R.drawable.asteroid10);
        _Bitmaps[ 1] = BitmapFactory.decodeResource(resources, R.drawable.asteroid11);
        _Bitmaps[ 0] = BitmapFactory.decodeResource(resources, R.drawable.asteroid12);
    }
    
	@Override
	protected void onStart(GamePlatformInfo platformInfo) {
		_Canvas = platformInfo.getCanvas();
		_Paint = platformInfo.getPaint();
	}
	
	@Override
	protected void onDraw(GamePlatformInfo platformInfo) {
		_Canvas.drawBitmap(_Bitmaps[_BitmapIndex], _X, _Y, _Paint);
		
		long tick = platformInfo.getTick();
		
		_TickCount = _TickCount + tick;
		if (_TickCount >= _ANIMATIONINTERVAL) {
			_TickCount = 0;
			_BitmapIndex++;
			if (_BitmapIndex >= _Bitmaps.length) {
				_BitmapIndex = 0;
			}
		}
		
		// 초당 _SPEED 만큼의 속도로 운석을 이동 한다.
		_DY = _DY + (_SPEED * tick / 1000);
		if (_DY >= 1) {
			_Y = _Y + ((int) _DY);
			_DY = _DY - ((int) _DY);
			
			if (_Y > _Canvas.getHeight()) _Y = 0;
		}
	}
	
}

갑자기 소스가 복잡해져 보입니다.  그러나 사실 별로 어려울 만한 코드는 없으니 천천히 살펴보도록 하겠습니다.  우선 Asteroid 클래스는 크게 아래와 같이 구성되어 있습니다.
  • public Asteroid(Context context, int x, int y) 
    • 생성자 
  • private void init(Context context) 
    • 초기화 메소드
  • protected void onStart(GamePlatformInfo platformInfo) 
    • 게임 컨트롤이 처음 가동되기 시작 할 때 한 번 실행 됩니다.
  • protected void onDraw(GamePlatformInfo platformInfo)
    • GamePlatform을 통해서 주기적으로 반복 호출 됩니다.
    • 게임 컨트롤을 그리는 코드를 구현하면 됩니다.
14-21: 생성자를 구현하고 있습니다.  Context 객체에 대한 레퍼런스와 처음 표시되어야 할 좌표를 지정하기 위하여 파라메터 선언 부분이 변경되었습니다.

37-52: 리소스에 있는 소행성 이미지를 배열로 정의하는 곳 입니다.  이미지는 Android SDK에 포함된 예제인 JeyBoy에서 복사해서 사용하고 있습니다.

55-58: 게임 컨트롤이 처음 가동 될 때, Canvas와 Paint 객체의 레퍼런스를 미리 저장해 두고 있습니다.  매번 레퍼런스를 가져오는 것보다 조금은 성능 향상을 기대 할 수 있습니다.  [소스 2]와 같이 매번 가져오는 방식을 취해도 문제 없습니다.

64: platformInfo.getTick()은 onTick(), onDraw() 등이 반복 될 때, 이전 반복과 현재 사이의 시간을 ms(밀리 세컨드) 단위로 알려주게 됩니다.  이처럼, platformInfo은 GamePlatform을 사용하는 데 필요한 여러 가지 정보가 담겨져 있습니다.

66-69: _TickCount에 계속 tick을 더해서 _ANIMATIONINTERVAL과 같아지거나 커지면 _BitmapIndex를 하나씩 증가 시키고 있습니다.  여기서 _ANIMATIONINTERVAL은 100이기 때문에 100ms 즉, 최대 1초에 10번씩 _BitmapIndex가 증가하게 됩니다.  그리고, 70-71: 라인에서 그 값이 _Bitmaps.length 보다 같거나 커지면 다시 0으로 순환 반복하도록 되어 있습니다.  이를 통해서 소행성 이미지를 100ms 마다 차례로 표시하면서 애니메이션 효과를 주게 됩니다.

73-80: 소행성을 회전시키면서 아래쪽으로 떨어트리는 효과를 주기 위해서 _Y 좌표를 _SPEED 만큼씩 전진 시키고 있습니다.  여기서 _Y를 직접 계산하지 않고 _DY라는 보조 변수를 통해서 실수 연산을 하다가 1보다 커졌을 경우만 적용시키고 있습니다.  이것은 반복하는 간격이 너무 짧아서 움직이는 값이 1 픽섹보다 작은 소수점 미만의 숫자가 되서 결국 0으로 취급되어 제 자리에 멈추는 것을 방지하기 위함 입니다.  아래와 같이 변경해서 실행 해 보시면 이해가 쉬울 것 입니다.
_Y = _Y + ((int) (_SPEED * tick / 1000));
81: 소행성이 화면을 벗어나면 다시 위에서 부터 떨어지도록 _Y 좌표 값을 0으로 지정하고 있습니다.


[그림 4]는 실행 결과 화면 입니다.  한 가지 문제가 있네요.  소행성이 움직이는 과정이 지워지지 않아서 흔적이 남고 있습니다.


[그림 4] 프로그램 실행 결과

 이제 흔적을 지우기 위해서 [소스 6]와 같이 BackGround 클래스를 추가하도록 하겠습니다.  [소스 5]의 Main.java 는 아래와 같이 수정해야 합니다.


[소스 5] Main.java 수정
package app.main;

import ryulib.game.GamePlatform;
import android.app.Activity;
import android.os.Bundle;
import android.view.ViewGroup;
import android.widget.LinearLayout;

public class Main extends Activity {
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        _GamePlatform = new GamePlatform(this);
        _GamePlatform.setLayoutParams(
        		new LinearLayout.LayoutParams(
        				ViewGroup.LayoutParams.FILL_PARENT, 
        				ViewGroup.LayoutParams.FILL_PARENT, 
        				0.0F
        		)
        );
        
        setContentView(_GamePlatform);
        
        _BackGround = new BackGround(null);
        _Asteroid = new Asteroid(this, 100, 0);
        
        _GamePlatform.AddControl(_BackGround);
        _GamePlatform.AddControl(_Asteroid);
    }
    
    private GamePlatform _GamePlatform = null;
    private BackGround _BackGround = null;
    private Asteroid _Asteroid = null; 
    
}
수정된 부분은 다른 색으로 표시되어 있습니다.  여기서 중요한 것은 _BackGround가 _Asteroid 먼저 _GamePlatform에 Add 되어야 한다는 것 입니다.  그래야 먼저 바탕이 그려지게 됩니다.  순서를 뒤바꾸는 방법도 있지만, 그것은 나중에 설명하도록 하겠습니다.


[소스 6] BackGround.java
package app.main;

import android.graphics.Canvas;
import android.graphics.Paint;
import ryulib.game.GameControl;
import ryulib.game.GameControlGroup;
import ryulib.game.GamePlatformInfo;

public class BackGround extends GameControl {

	public BackGround(GameControlGroup gameControlGroup) {
		super(gameControlGroup);
	}

	private Canvas _Canvas = null;
	private Paint _Paint = null;
	
	@Override
	protected void onStart(GamePlatformInfo platformInfo) {
		_Canvas = platformInfo.getCanvas();
		_Paint = platformInfo.getPaint();
	}
	
	@Override
	protected void onDraw(GamePlatformInfo platformInfo) {
		_Paint.setARGB(255, 0, 0, 0);
		_Canvas.drawRect(0, 0, _Canvas.getWidth(), _Canvas.getHeight(), _Paint);
	}
	
}

27: BackGound는 현재 화면을 26: 라인에서 지정한 검은 색으로 전체 화면을 덮어서 그려주고 있습니다.  따라서 방금 전에 그려졌던 소행성의 이미지가 사라지게 됩니다.  이후 소행성의 위치가 변경된 것이 BackGround 위에 표시돕니다.  BackGround에 배경 이미지를 스크롤 하는 방법은 나중에 다뤄보도록 하겠습니다.

[그림 5]는 변경된 내용이 적용된 프로그램의 실행 장면 입니다.

[그림 5] BackGround 적용 후 



KeyEvent 처리

스마트 폰 게임에서 방향키를 이용해서 케릭터를 움직이는 경우는 드물겠지만, 키보드의 방향키를 이용해서 떨어지는 소행성을 조종해보도록 하겠습니다.

[소스 7]은 [소스 6]의 Main.java를 수정한 내용입니다.  수정한 내용은 단 한 줄 입니다.


[소스 7] Main.java 2차 수정
package app.main;

import ryulib.game.GamePlatform;
import android.app.Activity;
import android.os.Bundle;
import android.view.ViewGroup;
import android.widget.LinearLayout;

public class Main extends Activity {
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        _GamePlatform = new GamePlatform(this);
        _GamePlatform.setLayoutParams(
        		new LinearLayout.LayoutParams(
        				ViewGroup.LayoutParams.FILL_PARENT, 
        				ViewGroup.LayoutParams.FILL_PARENT, 
        				0.0F
        		)
        );
        _GamePlatform.setUseKeyEvent(true);
        
        setContentView(_GamePlatform);
        
        _BackGround = new BackGround(null);
        _Asteroid = new Asteroid(this, 100, 0);
        
        _GamePlatform.AddControl(_BackGround);
        _GamePlatform.AddControl(_Asteroid);
    }
    
    private GamePlatform _GamePlatform = null;
    private BackGround _BackGround = null;
    private Asteroid _Asteroid = null; 
    
}
23: 키보드 이벤트를 사용하려면 _GamePlatform.setUseKeyEvent(true)를 통해서 이벤트를 사용하겠다고 알려야 합니다.  기본으로는 _GamePlatform.setUseKeyEvent(false)로 되어 있어서 키보드 이벤트를 무시하도록 되어 있습니다.

이후 Asteroid.java에는 다음과 같이 onKeyDown() 메소드를 추가로 구현해 주면 키보드를 통한 수평 이동이 완성됩니다.

	@Override
	protected boolean onKeyDown(GamePlatformInfo platformInfo, int keyCode, KeyEvent msg) {
		switch (keyCode) {
			case KeyEvent.KEYCODE_DPAD_LEFT: _X--; break;
			case KeyEvent.KEYCODE_DPAD_RIGHT: _X++; break;
		}
		
		return true;
    }
3-6: 키보드 이벤트의 keyCode에 따라서 _X 좌표를 왼쪽 또는 오른쪽으로 이동하고 있습니다.  

실제 게임에서는 좀 더 부드러운 방법을 사용해야 합니다.  
키보드는 눌러지는 당시와 계속 누르고 있을 때의 반응 속도가 다르기 때문입니다.

8: true를 리턴하게 되면, 키보드 이벤트 처리가 완료되었음을 GamePlatform에게 알리게 됩니다.  따라서, 뒤에 실행되는 게임 컨트롤의 해당 이벤트 처리를 무시하게 됩니다.



MotionEvent 처리

이번에는 MotionEvent 처리에 대한 예제를 살펴보도록 하겠습니다.  사용자가 소행성을 터치하면 소행성을 사라지도록 하겠습니다.

이번에도 Main.java를 [소스 8]과 같이 한 줄 수정 합니다.


[소스 8] Main.java 3차 수정
package app.main;

import ryulib.game.GamePlatform;
import android.app.Activity;
import android.os.Bundle;
import android.view.ViewGroup;
import android.widget.LinearLayout;

public class Main extends Activity {
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        _GamePlatform = new GamePlatform(this);
        _GamePlatform.setLayoutParams(
        		new LinearLayout.LayoutParams(
        				ViewGroup.LayoutParams.FILL_PARENT, 
        				ViewGroup.LayoutParams.FILL_PARENT, 
        				0.0F
        		)
        );
        _GamePlatform.setUseKeyEvent(true);
        _GamePlatform.setUseMotionEvent(true);
        
        setContentView(_GamePlatform);
        
        _BackGround = new BackGround(null);
        _Asteroid = new Asteroid(this, 100, 0);
        
        _GamePlatform.AddControl(_BackGround);
        _GamePlatform.AddControl(_Asteroid);
    }
    
    private GamePlatform _GamePlatform = null;
    private BackGround _BackGround = null;
    private Asteroid _Asteroid = null; 
    
}
24: 키보드 이벤트와 마찬가지로 기본으로는 MotionEvent 처리가 꺼져 있습니다.  setUseMotionEvent(true)을 통해서 사용하겠다고 GamePlatform에게 알려줘야 합니다.
Asteroid.java에는 다음과 같이 onTouchEvent() 메소드를 추가 합니다.
	@Override
    protected boolean onTouchEvent(GamePlatformInfo platformInfo, MotionEvent event) {
		int x = (int) event.getX();
		int y = (int) event.getY();
		
		boolean isHori = (x >= _X) && (x <= (_X+_AsreroidSize));
		boolean isVert = (y >= _Y) && (y <= (_Y+_AsreroidSize));		
		if (isHori && isVert) this.Delete();
		
		return true;
    }
3-4: 터치가 일어난 (x, y) 좌표를 구해오고 있습니다.

6-8: (x, y) 좌표가 소행성의 (_X, _Y) 좌표에서 시작되는 소행성 크기만큼의 사각형 영역 안에 있으면 this.Delete()를 통해서 소행성을 삭제하고 있습니다.



정리

지금까지 게쪽의 기본적인 사용법을 살펴보았습니다.  이후 충돌 테스트 등의 다양한 내용은 실제 게임을 작성해 가면서 알아보도록 하겠습니다.



소스

Add External JARs를 통해서 RyuLib 설치를 못하셔서 질문하시는 분들이 계셔서 소스에 RyuLib를 중복해서 포함 시키고 있습니다.  간단한 몇 개를 만들어 보면서 게임 쪽 라이브러리를 테스트 해왔지만, 앞으로도 계속 변경 될 수 있으니 이점 유의하시기 바랍니다.  어느 정도 안정화가 되면 SVN을 통해서 소스를 공식 배포하도록 하겠습니다.


관련글 더보기