Table of contents
[Tip] JNI
JNI란 Java Native Interface의 약자로 Java code에서 Native (C/C++) 언어의 call을 가능하게 해준다.
또한 JNI를 사용하면 Native(C/C++) 에서 Java로의 call(흔히 callback이라 부름)역시 가능하다.
또한 JNI를 사용하면 Native(C/C++) 에서 Java로의 call(흔히 callback이라 부름)역시 가능하다.
1. Java 진영 용어 파악.
JNI를 알기 전에 Java 진영에서 사용하는 용어, 개념을 파악한다.
1.1 Java Byte Code
Java 소스 파일( xxx.java )을 Java compiler(javac)로 compile 한 컴파일된 결과물(.class)을 Java Byte Code라고 한다. 자바 컴파일러는 C/C++등의 컴파일러처럼 직접적인 CPU 명령으로 변환하는 것이 아니라, 개발자가 이해하는 자바 언어를 JVM이 이해하는 Java Byte Code로 번역한다.
1.2 JVM
JVM은 Java Virtual Machine 이며 Java Byte Code를 읽어 실행한다. 참고로 Java Byte Code는 플랫폼 의존적인 코드가 없다. OS나 Platform위에 JVM이 있으므로 특정 Platform에 JVM이 설치되어 있다면 CPU나 운영체제가 다르더라도 동일한 Java Byte Code가 실행될 수 있다. 이는 Java가 초기 WORA(Write Once Run Anywhere)를 목표로 설계되었기 때문이다.
1.3 JRE
JRE는 Java Runtime Environment (자바 실행환경)이다. JVM이 Java 프로그램을 동작시킬 때 필요한 라이브러리 파일과 기타 파일들을 포함한다.
1.4 JDK
JDK는 Java Development Kit (자바 개발도구)이다. JDK는 JRE에 Java 개발에 필요한 도구(javac(컴파일러), JVM) 들을 포함한다.
1.5 NDK
NDK는 Native Development Kit 이며, C/C++(Native Code)등의 언어로 앱을 일부를 구현할 수 있는 도구 집합이다. NDK는 C/C++로 된 코드를 Android 에서 사용될 수 있는 Library나 binary로 만들어 주는 kit이다. 쉽게 생각해서 C/C++ 코드를 Android에서 동작하게 빌드하는 toolchain 이 포함된 kit이라고 생각하면 될 것 같다.
1.6 JNI와 NDK
JNI와 NDK가 조금 해깔릴 수 있다.
NDK를 이용하면 C/C++ 언어로 된 코드를 Android에서 동작하는 Library나 Binary를 만들 수 있다.
JNI는 이용하면 Java 코드에서 C/C++로 만들어진 Library(NDK를 이용하여 생성된 Library)를 Call 할 수 있다.
JNI는 이와 같이 Java에서 C/C++ Library를 Call 할 수 있는 Interface를 제공한다.
NDK를 이용하면 C/C++ 언어로 된 코드를 Android에서 동작하는 Library나 Binary를 만들 수 있다.
JNI는 이용하면 Java 코드에서 C/C++로 만들어진 Library(NDK를 이용하여 생성된 Library)를 Call 할 수 있다.
JNI는 이와 같이 Java에서 C/C++ Library를 Call 할 수 있는 Interface를 제공한다.
2. JNI 동작 원리
2.1 JNI Layer
JNI 모듈은 아래와 같이 JVM에 포함되어 있다.
Java Bytecode를 JVM이 해독할 때, Native Call을 수행하는 Java 코드가 있으면 JNI를 통하여 해당 Java Code와 Native Lib에서 일치하는 Native Function을 Mapping 한다.
Java Bytecode를 JVM이 해독할 때, Native Call을 수행하는 Java 코드가 있으면 JNI를 통하여 해당 Java Code와 Native Lib에서 일치하는 Native Function을 Mapping 한다.
참고로 아래는 JVM 내부에 있는 JNI의 코드위치이다. JVM은 JDK(Java Development Kit)을 다운받으면 포함되어 있다. JVM에 JNI가 포함되어 있음을 알 수 있다.
2.2 Function Mapping
JNI의 핵심은 Java Method와 Native Function을 Mapping하는데 있다고 생각한다. JNI를 통해 Java에서 Native Function Call을 수행한다고 하였는데, JNI에서 어떻게 Mapping을 하는지 확인해본다. Mapping 방법에는 아래 두가지가 있으며 각각에 대해서 살펴본다.
2.2.1 자동 Mapping
이 방법을 사용하면 프로그래머가 직접 명시하지 않아도 자동으로 Java method와 Native function간의 mapping을 JNI가 해준다. 하지만 이 방법을 사용하기 위해서는 Native Function이 JNI에서 인식될 수 있도록 약속된 format이여야 가능하다.
자동 Mapping을 통해 Native를 call하는 Java 코드와 cpp 코드를 살펴본다.
Java Code
Java Code
package com.example.jnitest3;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.widget.TextView;
public class MainActivity extends AppCompatActivity {
// Used to load the 'native-lib' library on application startup.
static {
System.loadLibrary("native-lib");
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// Example of a call to a native method
TextView tv = findViewById(R.id.sample_text);
tv.setText(stringFromJNI());
}
/**
* A native method that is implemented by the 'native-lib' native library,
* which is packaged with this application.
*/
public native String stringFromJNI();
}
위의 코드는 작게 나눠 아래와 같은 일을 수행한다.
- Native Lib Load
Native Lib를 Load 해야 하며, 아래와 같이 Load 할 수 있다.static { System.loadLibrary("native-lib"); }
static으로 load를 해야 프로그램 시작시 native lib를 load 한다. - Native Function 호출을 위한 method 선언
Native Function을 위한 method는 아래와 같이 지정 한다./* * A native method that is implemented by the 'native-lib' native library, * which is packaged with this application. */ public native String stringFromJNI();
특히 주의할 점으로 native keyword를 붙어야 Native method인지를 알 수 있다. - Native metohd Call
TextView에 Native Method Call을 수행하는 것을 확인할 수 있다.
native method를 call하면 JNI에서 native method와 매핑된 cpp function을 호출한다.// Example of a call to a native method TextView tv = indViewById(R.id.sample_text); tv.setText(stringFromJNI());
CPP 코드
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_jnitest3_MainActivity_stringFromJNI(
JNIEnv *env,
jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
여기서 주의해야 할 점은 cpp 함수의 함수명에 있다.
java에서 xxx 이라는 method를 호출했다면 자동 mapping을 통해 cpp에서 호출되는 함수는 “Java_class명_method명” 이다. 만약 class가 특정 package에 포함되어 있다면 package 명까지 같이 기입한다.
(패키지명에서 '.'은 ‘_’ 로 표기한다.)
java에서 xxx 이라는 method를 호출했다면 자동 mapping을 통해 cpp에서 호출되는 함수는 “Java_class명_method명” 이다. 만약 class가 특정 package에 포함되어 있다면 package 명까지 같이 기입한다.
(패키지명에서 '.'은 ‘_’ 로 표기한다.)
즉 Java 코드에서 stringFromJNI method를 지정했기 때문에 해당 method 호출시 수행되는 cpp Native단의 function은 Java_com_example_jnitest3_MainActivity_stringFromJNI 이다.
2.2.2 수동 Mapping
자동 mapping보다 수동 mapping이 우선순위가 높다. 만약 특정 native method에 대해 수동 mapping된 cpp function이 있으면 수동 mapping된 function이 수행된다.
수동 Mapping은 Java method와 cpp function의 mapping을 직접 기입해줘야 한다.
복잡해 보일수 있으나 mapping 하는 것 자체가 JNI function call을 통한 인자로 전달하는 것이기 때문에 복잡하지 않다.
수동 Mapping은 Java method와 cpp function의 mapping을 직접 기입해줘야 한다.
복잡해 보일수 있으나 mapping 하는 것 자체가 JNI function call을 통한 인자로 전달하는 것이기 때문에 복잡하지 않다.
수동 Mapping은 XA402H 의 HmxTunerApp이 수동 Mapping을 사용하고 있기 때문에 HmxTunerApp의 소스코드로 설명한다.
(HmxTunerApp 위치 : vendor/humax/hhal/testApp/HmxTunerApp)
(HmxTunerApp 위치 : vendor/humax/hhal/testApp/HmxTunerApp)
Java Code
(vendor/humax/hhal/testApp/HmxTunerApp/src/com/humax/tuner/hmxtunerapp/HmxTunerHalTest.java)
(vendor/humax/hhal/testApp/HmxTunerApp/src/com/humax/tuner/hmxtunerapp/HmxTunerHalTest.java)
Java Code는 자동 Mapping이나 수동 Mapping이나 차이가 없다.
native 로 호출하는 method는 native로 명시를 하고, native lib를 System.loadLibrary 를 호출하여 Load 한다.
native 로 호출하는 method는 native로 명시를 하고, native lib를 System.loadLibrary 를 호출하여 Load 한다.
package com.humax.tuner.hmxtunerapp;
//import com.humax.tuner.HmxTunerHalTest;
import android.app.Activity;
import android.os.Bundle;
import android.util.Log;
import android.view.Surface;
public class HmxTunerHalTest extends Activity {
private static final String TAG = HmxTunerHalTest.class.getSimpleName();
private native void initialize_tuner();
private native void uninitialize_tuner();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Log.d(TAG, "[HUMAX][APK]+hmxtunerapp");
initialize_tuner();
Log.d(TAG, "[HUMAX][APK]-hmxtunerapp");
}
@Override
protected void onDestroy() {
// TODO Auto-generated method stub
uninitialize_tuner();
super.onDestroy();
}
/**
* ---------------------------------------------------------------------
* Auto-load the JNI library
* ---------------------------------------------------------------------
*/
static {
System.loadLibrary("hmxtunerhaltest_jni");
}
}
CPP 코드
static void initialize_tuner(JNIEnv *env, jobject obj)
{
ALOGD("[HUMAX][JNI]+%s", __FUNCTION__);
int rc = 0;
ituner();
if(ituner() != NULL)
{
ALOGD("[HUMAX][JNI] Call HHalTvInput->initializeTuner(100)", __FUNCTION__);
rc = ituner()->initializeTuner(0);
rc = ituner()->setTune(100, 200);
}
ALOGD("[HUMAX][JNI]-%s", __FUNCTION__);
return ;
}
static void uninitialize_tuner(JNIEnv *env, jobject obj)
{
ALOGD("[HUMAX][JNI]+%s", __FUNCTION__);
int rc = 0;
if(ituner() != NULL)
{
rc = ituner()->uninitializeTuner(0);
}
ALOGD("[HUMAX][JNI]-%s", __FUNCTION__);
return;
}
const JNINativeMethod g_methods[] = {
{ "initialize_tuner", "()V", (void*)initialize_tuner },
{ "uninitialize_tuner", "()V", (void*)uninitialize_tuner },
};
int register_com_humax_tuner(JNIEnv *env)
{
jclass clazz = env->FindClass("com/humax/tuner/hmxtunerapp/HmxTunerHalTest");
if (clazz == NULL)
{
ALOGE("Can't find com/humax/tuner/hmxtunerapp/HmxTunerHalTest");
return -1;
}
if (jniRegisterNativeMethods(
env, "com/humax/tuner/hmxtunerapp/HmxTunerHalTest", g_methods, NELEM(g_methods)) < 0) {
return JNI_ERR;
}
return JNI_VERSION_1_6;
}
int JNI_OnLoad(JavaVM *jvm, void* /* reserved */)
{
JNIEnv *env;
if (jvm->GetEnv((void**)&env, JNI_VERSION_1_6)) {
return JNI_ERR;
}
return register_com_humax_tuner(env);
}
CPP 코드에서는 수동 mapping인 경우 직접 mapping 하는 코드가 들어있으며 아래 절차대로 진행된다.
- JNI_OnLoad 함수
Java에서 System.loadLibrary 함수를 호출 하면 전달된 Library에서 JNI_OnLoad 함수가 있는지 찾고, 만약 JNI_OnLoad 함수가 있으면 JNI_OnLoad 함수를 실행시킨다.int JNI_OnLoad(JavaVM *jvm, void* /* reserved */) { JNIEnv *env; if (jvm->GetEnv((void**)&env, JNI_VERSION_1_6)) { return JNI_ERR; } return register_com_humax_tuner(env); }
JNI_OnLoad 함수에서는 JNIEnv 포인터를 얻을 수 있는데, JNIEnv 포인터는 JNI Function Pointer table을 가리키고 있어 JNI 관련 함수들을 수행할 수 있게 해준다. - register_xxx 함수
register_xxx 함수는 JNI_OnLoad 함수에서 호출하는 사용자가 정의한 함수이며, 일반적으로 해당 함수에서 native와 연결될 Java class 를 찾은 뒤, jniRegisterNativeMethods 함수를 통해 native function과 java method를 연결시킨다.int register_com_humax_tuner(JNIEnv *env) { jclass clazz = env->FindClass("com/humax/tuner/hmxtunerapp/HmxTunerHalTest"); if (clazz == NULL) { ALOGE("Can't find com/humax/tuner/hmxtunerapp/HmxTunerHalTest"); return -1; } if (jniRegisterNativeMethods( env, "com/humax/tuner/hmxtunerapp/HmxTunerHalTest", g_methods, NELEM(g_methods)) < 0) { return JNI_ERR; } return JNI_VERSION_1_6; }
- g_methods 구조체
jniRegisterNativeMethods 함수에 넘겨주는 JNINativeMethod(g_methods)에 매핑정보가 기입되어 있으며 jni 는 해당 구조체를 보고 java method와 native function을 매핑을 한다.const JNINativeMethod g_methods[] = { { "initialize_tuner", "()V", (void*)initialize_tuner }, { "uninitialize_tuner", "()V", (void*)uninitialize_tuner }, };
JNINativeMethod 에는 아래의 3가지 값을 가진다.
첫번째는 java의 method명
두번째는 signature
세번째는 cpp의 매핑될 function명첫번째와 세번째 인자는 별도의 설명이 필요없으며, 두번째 인자인 signature는 Java method의 인자와 반환값을 나타내기 위해 필요한 기호 문자열이다.우선 각 Field를 나타내는 방법은 다음과 같다.기본형Field Descriptor Java Language Type Z boolean B byte C char S short I int J long F float D double 배열 : 앞에 "["를 붙인다.
객체 : L + class path + ;example :Field Description Java Language Type “Ljava/lang/String;” String “[I” int[] “[Ljava/lang/Object;” Object[] 위의 규칙을 바탕으로 ()안에 argument를 순서대로 먼저 적고, 그 후 return value를 기입하면 된다.example :Method Descriptor Java Language Type “()LJava/lang/String;” String f(); “(ILjava/lang/Class;)J” long f(int i, Class c); “([B)V” void f(byte[] bytes); - jniRegisterNativeMethods 함수
해당 함수는 JNIHelp.c에 정의되어 있는 함수이고, JNI를 보다 편리하게 사용하기 위해 wrapping 해놓은 것에 불과하다. (참고로 JNIHelp.c는 libnativehelper에 포함된 코드이고, ndk toolchain에는 포함되지 않기 때문에 androidstudio에서는 jniRegisterNativeMethods 함수를 사용할 수 없다. androidstudio에서는 직접 JNIEnv를 통해 register 해야 한다.)실제로 해당 함수 내부를 찾아보면 JNIEnv 포인터를 통해 JNINativeMethod 를 register 하는 동작을 수행한다.extern "C" int jniRegisterNativeMethods(C_JNIEnv* env, const char* className, const JNINativeMethod* gMethods, int numMethods) { JNIEnv* e = reinterpret_cast<JNIEnv*>(env); ALOGV("Registering %s's %d native methods...", className, numMethods); scoped_local_ref<jclass> c(env, findClass(env, className)); if (c.get() == NULL) { char* tmp; const char* msg; if (asprintf(&tmp, "Native registration unable to find class '%s'; aborting...", className) == -1) { // Allocation failed, print default warning. msg = "Native registration unable to find class; aborting..."; } else { msg = tmp; } e->FatalError(msg); } if ((*env)->RegisterNatives(e, c.get(), gMethods, numMethods) < 0) { char* tmp; const char* msg; if (asprintf(&tmp, "RegisterNatives failed for '%s'; aborting...", className) == -1) { // Allocation failed, print default warning. msg = "RegisterNatives failed; aborting..."; } else { msg = tmp; } e->FatalError(msg); } return 0; }
2.3 자료형
Function Mapping시 한가지 주의할 점이 있다.
Native Function이 호출/정의될때 매개변수나 return value가 C나 CPP에서 사용하는 자료형으로 전달되지 않는다.
JNI에서 정의한 자료형으로 전달되기 때문에 Native단에서 자료형에 따라 적절히 Converting 해야 하며 converting시에 JNIEnv의 converting function을 사용해야 한다.
Native Function이 호출/정의될때 매개변수나 return value가 C나 CPP에서 사용하는 자료형으로 전달되지 않는다.
JNI에서 정의한 자료형으로 전달되기 때문에 Native단에서 자료형에 따라 적절히 Converting 해야 하며 converting시에 JNIEnv의 converting function을 사용해야 한다.
2.3.1 Primitive Type (기본 자료형)
기본 자료형의 경우는 별다른 Type 변환 없이 사용가능하다.
jni.h 파일을 확인해 보면 기본 자료형이 아래와 같이 정의되어 있다.
jni.h 파일을 확인해 보면 기본 자료형이 아래와 같이 정의되어 있다.
/*
* JNI Types (jni.h)
*/
#ifndef JNI_TYPES_ALREADY_DEFINED_IN_JNI_MD_H
typedef unsigned char jboolean;
typedef unsigned short jchar;
typedef short jshort;
typedef float jfloat;
typedef double jdouble;
typedef jint jsize;
다만 주의할 점으로 C/C++의 변수 타입이 JNI Type과 완전하게 일치하지 않는다. 한 예로 1byte 크기의 char에 비해 jchar는 unsigned short로 2bytes를 사용한다.
2.3.2 문자열
2.3.3 배열
2.3.4 객체형
Java의 String 값의 경우 아래와 같이 _jobject를 상속받은 class의 pointer를 define하여 사용하고 있다.(?)
class _jobject {};
class _jclass : public _jobject {};
class _jthrowable : public _jobject {};
class _jstring : public _jobject {};
class _jarray : public _jobject {};
class _jbooleanArray : public _jarray {};
class _jbyteArray : public _jarray {};
class _jcharArray : public _jarray {};
class _jshortArray : public _jarray {};
class _jintArray : public _jarray {};
class _jlongArray : public _jarray {};
class _jfloatArray : public _jarray {};
class _jdoubleArray : public _jarray {};
class _jobjectArray : public _jarray {};
typedef _jobject *jobject;
typedef _jclass *jclass;
typedef _jthrowable *jthrowable;
typedef _jstring *jstring;
typedef _jarray *jarray;
typedef _jbooleanArray *jbooleanArray;
typedef _jbyteArray *jbyteArray;
typedef _jcharArray *jcharArray;
typedef _jshortArray *jshortArray;
typedef _jintArray *jintArray;
typedef _jlongArray *jlongArray;
typedef _jfloatArray *jfloatArray;
typedef _jdoubleArray *jdoubleArray;
typedef _jobjectArray *jobjectArray;
이를 Native단에서 사용하기 위해서는 아래의 예와 같이 JNIEnv의 GetStringUTFChars 함수를 이용해야 converting이 가능하다.
그리고 사용이 끝난 이후에는 반드시 ReleaseStringUTFChars를 통해 release를 해야 한다.
그리고 사용이 끝난 이후에는 반드시 ReleaseStringUTFChars를 통해 release를 해야 한다.
JNIEXPORT jstring JNICALL Java_xxx_getString(JNIEnv *env, jobject obj, jstring prompt)
{
const char *str = (*env)->GetStringUTFChars(env, prompt, 0);
printf("%s", str);
/* release the memory allocated for the string operation */
(*env)->ReleaseStringUTFChars(env, prompt, str);
}
String이외의 다른 자료형도 모두 비슷한 방법으로 converting해서 사용해야 한다.
2.4 callback (Native -> Java Call)
Native에서 Java단을 Call 하는 것은 예제를 보고 이해하는 것이 빠를것 같다.
참고로 아래 코드는 java단에서 c단의 nativeMethod를 call 하는데 c단에서는 java단의 callback 함수를 호출한다.
java단의 callback 함수 내부에서는 다시 c단의 nativeMethod를 호출하도록 하여 java단의 callback이 5번 호출되도록 작성된 코드이다.
Java Code
java단의 callback 함수 내부에서는 다시 c단의 nativeMethod를 호출하도록 하여 java단의 callback이 5번 호출되도록 작성된 코드이다.
Java Code
class Callbacks {
private native void nativeMethod(int depth);
private void callback(int depth) {
if (depth < 5) {
System.out.println("In Java, depth = " + depth + ", about to enter C");
nativeMethod(depth + 1);
System.out.println("In Java, depth = " + depth + ", back from C");
} else
System.out.println("In Java, depth = " + depth + ", limit exceeded");
}
public static void main(String args[]) {
Callbacks c = new Callbacks();
c.nativeMethod(0);
}
static {
System.loadLibrary("MyImpOfCallbacks");
}
}
위의 java code는 nativeMethod 라는 native단 method를 1개 가지고 있으며 main 함수에서 nativeMethod를 call 하고 있다.
c code
JNIEXPORT void JNICALL
Java_Callbacks_nativeMethod(JNIEnv *env, jobject obj, jint depth)
{
jclass cls = (*env)->GetObjectClass(env, obj);
jmethodID mid = (*env)->GetMethodID(env, cls, "callback", "(I)V");
if (mid == 0)
return;
printf("In C, depth = %d, about to enter Java\n", depth);
(*env)->CallVoidMethod(env, obj, mid, depth);
printf("In C, depth = %d, back from Java\n", depth);
}
위의 c 코드는 java에서 nativeMethod를 호출했을때 수행되는 함수이다.
c코드를 보면 JNI의 GetObjectClass 함수를 이용해서 jclass를 획득하고, GetMethodID를 이용하여 “callback” 이라고 명시된 method(jmethodID)를 해당 class에서 찾는다.
c단에서 java단으로 call을 할때는 JNI 함수인 CallVoidMethod를 통하여 call을 할 수 있으며, 이때 GetMethodID를 통해 획득한 methodID를 인자로 넘겨주어 해당 함수가 호출되도록 한다. callback 함수의 매개변수는 methodID 뒤에 순차적으로 넣어주면 된다.
c코드를 보면 JNI의 GetObjectClass 함수를 이용해서 jclass를 획득하고, GetMethodID를 이용하여 “callback” 이라고 명시된 method(jmethodID)를 해당 class에서 찾는다.
c단에서 java단으로 call을 할때는 JNI 함수인 CallVoidMethod를 통하여 call을 할 수 있으며, 이때 GetMethodID를 통해 획득한 methodID를 인자로 넘겨주어 해당 함수가 호출되도록 한다. callback 함수의 매개변수는 methodID 뒤에 순차적으로 넣어주면 된다.
참고로 예제에서 사용된 함수는 void return을 가지는 함수인데, 만약 다른 return type을 가지는 함수인 경우 CallBooleanMethod, CallIntMethod등과 같은 return type에 맞는 JNI 함수를 사용하면 된다.
만약 java단의 callback 함수가 static method라고 한다면 c단에서는 아래와 같이 callback 함수를 호출할 수 있다.
JNIEXPORT void JNICALL
Java_Callbacks_nativeMethod(JNIEnv *env, jobject obj, jint depth)
{
jclass cls = (*env)->GetObjectClass(env, obj);
jmethodID mid = (*env)->GetStaticMethodID(env, cls, "incDepth", "(I)I");
if (mid == 0)
return;
depth = (*env)->CallStaticIntMethod(env, cls, mid, depth);
}
methodID를 획득할때 GetStaticMethodID 라는 JNI 함수를 사용하여 ID를 획득하고, CallStaticIntMethod 라는 형식의 함수를 이용하여 static 함수를 호출한다. (역시 return type에 따라 CallStaticVoidMethod등과 같이 맞게 사용하면 된다.)
여기서 일반 method와 static method 호출시 눈여겨 볼 점은 CallVoidMethod의 경우 object를 넘겨줘야 하나 CallStaticVoidMethod의 경우 class를 넘겨줘야 한다.
이는 일반 method의 경우 object에서 수행되는 함수이나, static method는 class 를 통해 수행되므로 이러한 형태가 된 것이라고 이해하면 될것 같다.
댓글
댓글 쓰기