=======================
=======================
=======================
출처: https://cpp11.tistory.com/15
[Unity3D] GameObject 생성과 삭제. Instantiate와 Destroy
Unity에서 Instantiate와 Destroy 함수를 이용하여 GameObject를 생성하고 삭제할 수 있다.
Instantiate는 다음과 같이 사용할 수 있다.
Instantiate(original, position, rotation); |
위와 같이 3가지의 매개변수를 가진다.
original - 생성할 GameObject로 현재 Scene에 있는 GameObject나 Prefab을 지정할 수 있다.
position - Vector3변수로 생성될 GameObject의 위치를 지정한다.
rotation - Quaternion변수로 생성될 GameObject의 회전값을 지정한다. 이변이 없는 한 원래 GameObject의 회전값 즉, original.transform.rotation으로 지정한다.
만약 총알을 생성하여 발사한다면 다음과 같이 바로 Rigidbody로 캐스팅하여 사용할 수 있다.
Rigidbody clone;
clone = Instantiate(bullet, gun.transform.position, bullet.transform.rotation) as Rigidbody;
clone.AddForce(transform.forward * 5000);
- Destroy
Destroy는 다음과 같이 사용할 수 있다.
Destroy(obj);
매개변수에 삭제할 Object를 넣어주기만 하면 된다.
만약 일정 딜레이를 준 후에 Object를 삭제하고 싶다면 다음과 같이 매개변수를 추가해주면 된다.
Destroy(obj, time);
time은 float 변수로 시간을 지정해 주기만 하면 된다.
- Instantiate와 Destroy의 문제점
예를 들어 총알과 같이 수없이 생성되고 삭제되는 GameObject가 있다고 하면 이를 계속 Instantiate와 Destory로 생성하고 삭제한다면 아무래도 시스템에 많은 부담을 줄 것이다. 따라서 이럴 경우에는 쓸 총알을 안보이는 곳에(주로 Sphere를 하나 만들어 그안에 숨겨놓는다) 넉넉히 만들어 놓고 List로 관리하며 position값만 바꿔가면서 꺼내쓰고 다시 넣는식으로 구성하는 것이 좋다.
=======================
=======================
=======================
유니티 오브젝트 구성이 *오브젝트(이름"main1") - 오브젝트텍스트(Text)UI(이름"Sub1") - 오브젝트이미지(Image)UI(이름"Sub2") |
이렇게 오브젝트안에 UI오브젝트가 2개가 들어 있을때.
오브젝트"main1"의 변수안에서 찾고접근하는 방법은
[SerializeField] private GameObject _sfMain1 = null; //여기서 유니티 오브젝트를 링크해준다.
GameObject sub1 = _sfMain1.transform.Find("Sub1").gameObject;
GameObject sub2 = _sfMain1.transform.Find("Sub2").gameObject;
Text textSub1 = sub1.GetComponent<Text>();
Image imageSub2 = sub2.GetComponent<Image>();
=======================
=======================
=======================
출처: https://cru6548.tistory.com/5
[ 대표적인 함수 ]
1. Object를 찾는 방법(전체)
- 비활성화된 Object는 못 찾음!
GameObject.Find("이름"); // Object의 이름으로 찾음. 가장 처음에 나오는 Object를 반환. GameObject.FindWithTag("..."); // 태그로 대상을 찾음. 가장 처음에 나오는 Object를 반환. GameObject.FindGameObjectsWithTag("..."); // 태그로 대상을 찾음. 같은 태그를 가진 Objects를 배열의 형태로 반환. |
2. Object를 찾는 방법(자식)
- 비활성화 된 Object를 찾을 수 있음!
transform.Find("..."); // Object의 이름을 찾음. 가장 처음에 나오는 Object를 반환. transform.GetChild(...); // 자식을 번호로 찾음. 0번째가 첫 번째 자식 |
3. FindChild("...");도 있는데, 이건 이제 사용되지 않는다. Find로 대체되었다.
- 예전에는 Find로 비활성화 된 Child를 찾지 못했기에 FindChild를 사용했으나, 이제는 그렇지 않는다.
- 사용하게되면 "Find("...")를 사용하는게 좋다는 메시지"가 나온다.
Object를 찾을 때에는 크게 2가지 클래스로 나뉘어 찾게 됩니다. GameObject 와 Transform 이 그것입니다.
GameObject는 일반적으로 전체 오브젝트에서 찾을 때 사용이되며, Transform은 Object에서 부모, 자식관의 관계에 놓인 Object를 찾기위해 사용됩니다.
기본적으로 Transform은 찾길 원하는 Object의 Transform을 얻고 싶을 때 사용하지만, 이를 통하여 GameObject나 Component 모두 얻을 수 있습니다.
<GameObject>
함수 이름 | 설명 |
Find | 오브젝트 이름으로 검색하여 가장 처음에 나오는 오브젝트를 GameObject로 반환한다. |
FindWIthTag | 태그 이름으로 검색해서 가장 처음에 나타난 오브젝트를 GameObject로 반환한다. |
FindGameObjectsWithTag | 태그 이름으로 검색해서 나타난 오브젝트 여러개를 GameObject 배열로 반환한다. |
GameObject.FindObjectOfType | 오브젝트형(혹은 컴포넌트의 형)으로 검색해서 가장 처음 나타난 오브젝트를 GameObject로 반환한다. (유효한 오브젝트만) |
GameObject.FindObjectsOfType | 오브젝트형(혹은 컴포넌트의 형)으로 검색해서 가장 처음 나타난 오브젝트 여러개를 GameObject 배열로 반환한다. (유효한 오브젝트만) |
<Transform>
함수 이름 | 설명 |
Find | Object의 이름으로 자식 오브젝트를 검색해, 가장 처음에 나타난 자식 오브젝트를 반환한다. |
GetComponentInChildren | 컴포넌트 형으로 자식 오브젝트를 검색해서 처음 나타난 자식 오브젝트를 반환한다. |
GetComponentsInChildren | 컴포넌트 형으로 자식 오브젝트를 검색해서 나타난 자식 오브젝트들의 배열을 반환한다. |
GetComponentInParent | 컴포넌트 형으로 부모 오브젝트를 검색해, 가장 처음에 나타난 부모 오브젝으를 반환한다. |
GetComponentsInParent | 컴포넌트 형으로 부모오브젝트를 검색해서 나타난 부모 오브젝트들의 배열을 반환한다. |
Transform.FindObjectOfType | 오브젝트형(혹은 컴포넌트의 형)으로 검색해서 가장 처음 나타난 오브젝트를 반환한다. (유효한 오브젝트만) |
Transform.FindObjectsOfType | 오브젝트형(혹은 컴포넌트의 형)으로 검색해서 나타난 여러개의 Object들을 배열의 형태로 반환한다. (유효한 오브젝트만) |
** 참고사이트 **
- http://prosto.tistory.com/146
- http://tenlie10.tistory.com/90
- https://docs.unity3d.com/kr/current/Manual/ControllingGameObjectsComponents.html
=======================
=======================
=======================
게임 오브젝트 생성 및 제거
일부 게임은 씬에 일정한 수의 오브젝트를 유지하지만 게임플레이 중에 캐릭터, 보물 및 기타 오브젝트를 만들고 제거하는 것은 매우 일반적입니다. Unity에서 게임 오브젝트는 기존 오브젝트의 새로운 복사본을 만드는 Instantiate 함수를 사용하여 만들 수 있습니다.
public GameObject enemy;
void Start() {
for (int i = 0; i < 5; i++) {
Instantiate(enemy);
}
}
사본이 만들어진 오브젝트가 씬에 있을 필요는 없습니다. 에디터의 프로젝트 패널에서 public 변수로 드래그한 프리팹을 사용하는 것이 더 일반적입니다. 또한 게임 오브젝트를 인스턴스화하면 오리지널에 있는 모든 컴포넌트가 복사됩니다.
또한 프레임 업데이트가 끝난 후 또는 선택적으로 짧은 시간 지연 후에 오브젝트를 파괴하는 Destroy 함수가 있습니다.
void OnCollisionEnter(Collision otherObj) {
if (otherObj.gameObject.tag == "Missile") {
Destroy(gameObject,.5f);
}
}
Destroy 함수는 게임 오브젝트 자체에 영향을 주지 않으면서 개별 컴포넌트를 파괴할 수 있습니다. 일반적인 실수는 다음과 같이 작성하는 것입니다.
Destroy(this);
…이것은 스크립트가 연결된 게임 오브젝트를 파괴하는 것이 아닌, 호출하는 실제 스크립트 컴포넌트를 파괴합니다.
시간 및 프레임 속도 관리(Time and Framerate Management)
=======================
=======================
=======================
출처: https://blogbicha.tistory.com/3
유니티 하이라키창에 빈 게임 오브젝트를 만든 후 다음의 스크립트를 추가한다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CubeControl : MonoBehaviour {
// Use this for initialization
void Start () {
GameObject cube = GameObject.CreatePrimitive(PrimitiveType.Cube); //큐브 오브젝트 생성
cube.transform.position = new Vector3(0, 0, 0); //큐브 포지션 설정
}
// Update is called once per frame
void Update () {
}
}
실행시켜보면 0,0,0좌표에 큐브가 생성된 것을 확인할 수 있다.
게임오브젝트를 큐브뿐만아니라 GameObject.CreatePrimitive(PrimitiveType.Cube); 에서 Cube를 Plane, Sphere, Capsule, Cylinder 등으로 바꾸면 여러가지
오브젝트로 실행이 가능하다.
그럼 조금 더 업그레이드 해서 큐브를 무한대로 생성시켜보자.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CubeControl : MonoBehaviour {
int i = 0;
// Use this for initialization
void Start () {
}
// Update is called once per frame
void Update () {
i=i+5;
GameObject cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
cube.transform.position = new Vector3(i, 0, 0);
}
}
이렇게되면 업데이트 메서드가 호출될때마다 x축으로 5의 간격으로 큐브가 생성될 것이다.
출처: https://blogbicha.tistory.com/3 [Technologies of Drone]
=======================
=======================
=======================
Scene뷰에서 미리 만들어놓은 오브젝트만 가지고 게임을 진행할 수 있으면 좋겠지만
거의 대부분의 경우는 그렇지 않다.
게임 중간에 오브젝트를 화면에 생성할 때 어떻게 해야하는지 알아보자.
(구글링 키워드 : Unity Instantiate a gameobject at runtime)
사실은 간단히, Instantiate 함수를 이용하면 된다.
public GameObject obj; void Start() { //Instantiate(생성할오브젝트, position, rotation) //Quaternion.identity = rotation(회전각)이 0,0,0 임을 의미 (회전없음) Instantiate (obj, new Vector3 (0, 0, 0), Quaternion.identity); }
위와 같이 해놓으면 게임오브젝트의 Inspector창에 obj 오브젝트를 선택할 수 있게되고,
Inspector에서 지정한 게임오브젝트가 위의 코드에서 지정한 위치에 생성된다.
(이때, 생성하려는 게임오브젝트는 Scene에 오브젝트로 생성되어 있어야 선택이 가능하다.)
이때 게임 실행 시에 Hierarchy 창을 보면, 선택한 게임오브젝트이름 (clone) 이라는 명칭으로
생성된 게임오브젝트를 볼 수 있다.
결국, 게임오브젝트를 카피하는 방식이다.
버튼을 클릭할 때 오브젝트가 생성되게 구성하려면,
일단 UI > Button 오브젝트를 생성한 뒤 + 버튼을 이용하여 OnClick 에 호출할 함수를 지정해주면 된다.
이때 호출 할 함수와 그것을 포함하고 있는 스크립트는 public이여야하고,
호출할 함수의 파라미터는 1개이거나 없어야 한다.
//class BoardManager public class BoardManager : MonoBehaviour { public GameObject obj; public void newObject(){ Instantiate (obj, new Vector3 (0, 0, 0), Quaternion.identity); } }
위와 같이 하면, 버튼 클릭 시 오브젝트가 생성되게 할 수 있고,
오브젝트 이름을 바꾸고 싶으면, 아래와 같이 함수를 변경해주면 된다.
public void newObject(string objName){ GameObject Instance = Instantiate (obj, new Vector3 (0, 0, 0), Quaternion.identity) as GameObject; Instance.name = objName; }
Prefab 이용하여 오브젝트 생성하기
그런데 사실 위의 예시와 같이 사용되는 경우는 별로 없을 것 같다.
예를 들면 적(Enemy)이 나타나는데 최초의 Scene에는 존재하지 않는다면?
게임 오브젝트를 정의하여 따로 저장해놓고 필요할 때 가져다 쓸 수는 없을까?
반복사용을 위해 저장해놓은 GameObject의 복사본이 바로 Prefab(프리팹)이다.
프리팹은 유니티에서 꼭 알아야 할 매우 중요한 개념으로, 아래 포스팅에 정리해두었다.
Prefab (프리팹 개념과 만드는 방법)
Prefab 은 여러 컴포넌트로 이미 구성이 완성된, 재사용 가능한 Game Object다...
blog.naver.com
그럼 프리팹을 사용하여 오브젝트를 생성(인스턴스화)해보자.
사실 위의 코드와 비슷하고, 똑같이 Instantiate함수를 사용하면 된다.
public Transform prefab; public void newObject(){ Instantiate (prefab, new Vector3 (0, 0, 0), Quaternion.identity); }
위의 코드가 있는 게임오브젝트의 Inspector창에서 prefab항목(변수)에
만들어놓은 프리팹을 drag하거나 select 버튼으로 선택하여 정의해주면 된다.
매뉴얼에 프리팹을 생성하는 과정까지 포함하여 아주 자세히 나와있어서 링크해본다.
=======================
=======================
=======================
유니티에서 오브젝트 풀 만들기 Object Pool 1 – Pooled Object 정의하기
오브젝트 풀 시리즈 전체
- 유니티에서 오브젝트 풀 만들기 Object Pool 1 – Pooled Object 정의하기
- 유니티에서 오브젝트 풀 만들기 Object Pool 2 – 오브젝트 풀 스크립트 만들기
- 유니티에서 오브젝트 풀 Object Pool 만들기 3 – 총알 발사하기
오브젝트 풀 Object Pool
오브젝트 풀은 객체들을 미리 배열이나 리스트에 저장해두고 필요할 때 활성화해서 쓰고, 다 쓴 후에는 비활성화해서 반환하는 방식으로 객체를 재사용하는 것을 말합니다. 이렇게 관리하면 객체를 메모리에서 해제하지 않기 때문에 메모리 관리면에서 얻을 수 있는 이점이 많습니다.
특히 모노 .Net을 사용하는 유니티의 경우 가비지 컬렉션 Garbage Collection이 발생하면 성능에 무리를 줄 수 있기 때문에, 가비지 컬렉션이 자주 발생하지 않도록 하는 방향으로 메모리를 관리하는 것이 좋습니다. 하지만 자주 사용하는 객체들은 메모리의 할당과 해제가 반복되기 때문에 이 경우에 오브젝트 풀을 사용하면 가비지 컬렉션이 발생하는 것을 피할 수 있습니다.
유니티에서 자주 사용하는 객체가 많은 경우에 필수적으로 사용해야 하는 오브젝트 풀을 만들어 보겠습니다. 오브젝트 풀을 만들 때 구현할 수 있는 방법이 많지만, 기능을 최소한으로 해서 예제를 진행하겠습니다.
아래에 나열한 두 가지를 기본으로 해서 예제를 진행하겠습니다. 사실, 필요한 객체를 검색할 때 이름으로 검색하는 것 보다는 숫자로 검색이 가능하도록 테이블을 미리 만들어서 구현하는 방법이 속도면에서 더 좋겠지만 개념을 이해하기 위해서 이름으로 바로 검색하는 오브젝트 풀을 만들겠습니다.
- 필요한 객체를 요청하고, 사용한 객체를 반환할 때 객체의 이름으로 검색
- 여러 객체들을 저장할 때 리스트List 사용
PooledObject
이번 강좌에서는 재사용 될 객체를 정의하는 클래스를 작성해보겠습니다. Create->C# Script 메뉴를 이용해서 스크립트를 추가한 뒤 PooledObject로 이름을 지정합니다. 스크립트 생성이 완료되었으면 아래 내용을 추가합니다.
[System.Serializable]
public class PooledObject
{
public string poolItemName = string.Empty;
public GameObject prefab = null;
public int poolCount = 0;
[SerializeField]
private List<GameObject> poolList = new List<GameObject>();
public void Initialize(Transform parent = null) { }
public void PushToPool(GameObject item, Transform parent = null) { }
public GameObject PopFromPool(Transform parent = null) { }
private GameObject CreateItem(Transform parent = null) { }
}
먼저 재사용 될 객체를 관리하는 데 필요한 변수들을 선언합니다. 각 변수들의 역할은 다음과 같습니다.
- poolItemName : 객체를 검색할 때 사용할 이름
- prefab : 오브젝트 풀에 저장할 프리팹
- poolCount : 초기화할 때 생성할 객체의 수
- poolList : 생성한 객체들을 저장할 리스트
변수 선언이 완료되었으면, 메소드를 추가해서 기능을 구현합니다. 위의 스크립트를 참고해서 필요한 함수들을 선언한 뒤 내용을 하나하나 채워보겠습니다. 먼저 Initialize 함수입니다.
public void Initialize(Transform parent = null)
{
for (int ix = 0; ix < poolCount; ++ix)
{
poolList.Add(CreateItem(parent));
}
}
이 함수는 PooledObject 객체를 초기화 할 때 처음 한번만 호출하고, poolCount에 지정한 수 만큼 객체를 생성해서 poolList 리스트에 추가하는 역할을 합니다. 이렇게 처음에 필요한 객체를 미리 생성해서 리스트에 저장해둡니다. Transform 타입의 parent 파라미터는 생성된 객체들을 정리하는 용도로 사용됩니다. 지정을 하지 않으면 ObjectPool 게임 오브젝트의 자식 게임 오브젝트로 기본 지정되고, parent에 다른 Transform 을 지정하면, 그 Transform의 자식 게임오브젝트로 지정됩니다.
그 다음에 PushToPool 함수를 작성하겠습니다.
public void PushToPool(GameObject item, Transform parent = null)
{
item.transform.SetParent(parent);
item.SetActive(false);
poolList.Add(item);
}
이 함수는 사용한 객체를 다시 오브젝트 풀에 반환할 때 사용할 함수입니다. 반환할 게임 오브젝트를 item 파라미터로 전달하고, 부모 Transform 정보가 필요한 경우 함께 전달합니다. 이 함수 역시 parent를 지정하지 않으면 기본으로 ObjectPool 게임 오브젝트의 자식으로 지정됩니다.
이어서 PopFromPool 함수를 작성합니다.
public GameObject PopFromPool(Transform parent = null)
{
if (poolList.Count == 0)
poolList.Add(CreateItem(parent));
GameObject item = poolList[0];
poolList.RemoveAt(0);
return item;
}
이 함수는 객체가 필요할 때 오브젝트 풀에 요청하는 용도로 사용할 함수입니다. 먼저 저장해둔 오브젝트가 남아있는 지 확인하고, 없으면 새로 생성해서 추가합니다. 그리고 미리 저장해 둔 리스트에서 하나를 꺼내고 이 객체를 반환합니다.
PopFromPool 함수 작성까지 완료했으면, 객체를 생성하는 데 사용할 CreateItem 함수를 작성하겠습니다.
private GameObject CreateItem(Transform parent = null)
{
GameObject item = Object.Instantiate(prefab) as GameObject;
item.name = poolItemName;
item.transform.SetParent(parent);
item.SetActive(false);
return item;
}
이 함수는 prefab 변수에 지정된 게임 오브젝트를 생성하는 역할을 합니다. PooledObject 클래스 안의 여러 곳에서 객체 생성이 필요할 때 마다 사용합니다. 내용을 살펴보면, prefab에 지정한 정보를 바탕으로 게임오브젝트를 새로 생성하고, poolItemName에 지정한 이름을 새로 생성한 게임오브젝트 이름으로 지정합니다. 이어서 부모 계층을 지정한 뒤에, 생성한 게임오브젝트를 비활성화시켜서 나중에 사용할 수 있도록 준비합니다.
CreateItem 함수까지 작성이 완료되면 PooledObject 클래스의 작성이 완료되었습니다. 다음 강좌에서, 열심히 작성한 PooledObject 클래스를 사용하는 오브젝트 풀 시스템을 만들어 보겠습니다.
내용 끝까지 읽어주셔서 감사합니다.
배너 클릭은 저에게 많은 힘이 됩니다.
감사합니다
=======================
=======================
=======================
출처: https://bluemeta.tistory.com/4
1. 빈 게임오브젝트(Empty GameObject)
빈 게임오브젝트는 트랜스폼(Transform) 컴포넌트만 가지고 있는 게임오브젝트를 말합니다.
유니티에서 빈 게임오브젝트를 종종 만들어야 할 때가 있는데요. 스크립트를 포함해 컴포넌트들은 독립적으로 씬에 존재할 수 없기 때문에 중요한 컴포넌트들을 씬에 담기 위해서 만들고는 합니다. 그리고 새로운 게임오브젝트들을 하나로 묶기 위해 부모 게임오브젝트가 되기 위해서 만들기도 하고요. 여러 용도가 있습니다.
빈 게임오브젝트라고 해서 아무것도 없는 것이 아닙니다.
2. 스크립트를 통해 빈 게임오브젝트 만들기
보통은 아래와 같이 유니티 에디터 안에서 게임오브젝트를 만들기 마련입니다.
단축키는 Ctrl + Shift + N / Cmd + Shift + N
하지만 아래와 같이 스크립트를 통해서 만들 수도 있습니다. Start() 메소드에 작성을 하면 씬이 시작할 때부터 빈 게임오브젝트를 만들 수 있습니다. (물론 이 스크립트 역시 다른 어느 게임오브젝트의 스크립트일 것입니다.)
1 2 3 |
void Start () { GameObject emptyGameObject = new GameObject("name"); } Colored by Color Scripter |
cs |
C#의 기본적인 인스턴스 생성 문법과 같죠? 게임오브젝트라는 클래스의 인스턴스를 생성하는 것이 곧 빈 게임오브젝트를 만들어내는 것입니다. 그런데 이 때문에 아래와 같이 유니티 생명주기 함수가 아닌 스크립트의 필드, 프로퍼티 생성 영역에 작성하게 될 수도 있는데요. 그렇게 되면 예외가 발생합니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public class NewBehaviourScript : MonoBehaviour { GameObject emptyGameObject = new GameObject("name"); // Use this for initialization void Start () { } // Update is called once per frame void Update () { } } Colored by Color Scripter |
cs |
예외 메시지는 아래와 같습니다.
UnityException:
nternal_CreateGameObject is not allowed to be called from a MonoBehaviour constructor (or instance field initializer),
call it in Awake or Start instead. Called from MonoBehaviour 'NewBehaviourScript'.
해석해보면 게임오브젝트를 생성하는 코드는 Awake()나 Start() 메소드에 써야 한다고 나옵니다. 꼭 확인해두세요.
2. 스크립트를 통해 만든 빈 게임오브젝트의 트랜스폼
name은 빈 게임오브젝트의 이름이 되고요. 기본적으로 아래와 같은 트랜스폼을 가지고 있습니다. 보시면 아시겠지만 모든 값들이 리셋되었을 때의 값입니다.
X | Y | Z | |
Position | 0 | 0 | 0 |
Rotation | 0 | 0 | 0 |
Scale | 1 | 1 | 1 |
3. 정리
- 빈 게임오브젝트는 트랜스폼 컴포넌트만 가지고 있는 게임오브젝트이다.
- 빈 게임오브젝트는 스크립트 상에서 게임오브젝트의 인스턴스를 만드는 것과 같다.
- 빈 게임오브젝트 생성 코드는 Awake()나 Start()처럼 생명주기 관련 메소드에서 작성해야만 한다.
- 스크립트로 생성된 빈 게임오브젝느는 기본적으로 리셋된 상태의 트랜스폼 컴포넌트를 가지고 있다.
=======================
=======================
=======================
*기타관련링크
- https://purygame.tistory.com/7
=======================
=======================
=======================
'게임엔진관련 > 유니티 엔진' 카테고리의 다른 글
[Unity] 유니티 스크립트를 통한 color 컬러 변경 관련 (0) | 2019.04.17 |
---|---|
[Unity] 유니티 오브젝트 선택, 클릭, 터치, 접근, 상호작용 이벤트 관련 (0) | 2019.04.16 |
[Unity] 유니티 레이아웃 관련 (0) | 2019.04.10 |
[Unity] 유니티 Random, 난수 관련 (0) | 2019.04.08 |
[Unity] 유니티 모바일 디바이스 소프트키보드 관련 (0) | 2019.04.07 |
댓글 영역