반응형
Android Context 완벽 이해 - 종류별 사용법과 메모리 누수 해결
Android 개발을 시작하면 가장 자주 마주치게 되는 개념 중 하나가 바로 'Context'입니다. 거의 모든 Android API 호출에서 등장하는 이 Context는 무엇이고, 왜 이렇게 중요할까요? Context는 현재 앱의 환경에 대한 정보와 시스템 리소스 및 서비스에 접근할 수 있는 인터페이스를 제공하는 핵심 클래스입니다. 이 글에서는 초보 개발자도 쉽게 이해할 수 있도록 Context의 기본 개념부터 Application Context와 Activity Context의 차이, 올바른 사용법, 메모리 누수 방지, Fragment와 Custom View에서의 활용까지 실무에 필요한 모든 내용을 다룹니다.
목차
1. Context의 개념과 역할
2. Context의 종류와 생명주기
3. Context 사용 시 주의사항과 메모리 누수
4. 실전 Context 활용 예제
5. 자주 묻는 질문 (FAQ)
#1. Context의 개념과 역할
Context는 직역하면 '맥락', '문맥'이라는 의미를 가지고 있습니다. Android에서 Context는 현재 앱의 환경에 대한 정보와 시스템 리소스 및 서비스에 접근할 수 있는 인터페이스를 제공합니다. 쉽게 말해, Context는 앱이 실행되는 환경에 대한 모든 정보를 담고 있는 객체라고 볼 수 있습니다.
1) Context의 정의
Android 공식 문서에서는 Context를 다음과 같이 정의합니다.
"인터페이스를 구현하고 앱의 특정 환경에 대한 정보를 제공하는 추상 클래스"
여기서 중요한 점은 Context가 추상 클래스라는 것입니다. 즉, Context 자체는 직접 사용할 수 없고, 이를 구현한 구체적인 클래스를 통해 사용합니다. 대표적으로 Activity, Service, Application 등이 Context를 상속받아 구현하고 있습니다.
. . . . .
2) Context가 제공하는 주요 기능
Context는 Android 앱 개발에서 필수적인 다양한 기능을 제공합니다. 이러한 기능들은 앱의 거의 모든 작업에 관여하므로 정확히 이해하는 것이 중요합니다.
(1) 리소스 접근
문자열, 이미지, 색상, 레이아웃 등 앱 리소스에 접근할 수 있습니다. res 폴더에 정의된 모든 리소스는 Context를 통해 불러올 수 있습니다.
① 문자열 리소스: getString(R.string.app_name)
② 색상 리소스: getColor(R.color.colorPrimary)
③ 드로어블 리소스: getDrawable(R.drawable.ic_launcher)
④ 레이아웃 인플레이션: LayoutInflater.from(context)
(2) 시스템 서비스 접근
Android 시스템이 제공하는 다양한 서비스에 접근할 수 있습니다.
① 위치 서비스(LocationManager)
② 알림 관리자(NotificationManager)
③ 센서 관리자(SensorManager)
④ 알람 서비스(AlarmManager)
⑤ 클립보드 서비스(ClipboardManager)
(3) Intent 처리와 컴포넌트 제어
다른 컴포넌트를 시작하거나 브로드캐스트를 전송할 수 있습니다. Activity 시작, Service 제어, BroadcastReceiver 등록 등의 작업이 가능합니다.
(4) 데이터 저장 및 관리
앱의 데이터를 저장하고 관리하는 기능을 제공합니다.
① 파일 작업: 내부/외부 저장소 파일 읽기/쓰기
② SharedPreferences: 간단한 키-값 데이터 저장
③ 데이터베이스 접근: SQLite 데이터베이스 생성 및 접근
④ 권한 확인: 특정 권한 보유 여부 확인
#2. Context의 종류와 생명주기
Context는 크게 Application Context와 Activity Context 두 가지 유형으로 나눌 수 있습니다. 각각의 생명주기와 특성을 이해하면 적절한 상황에서 올바른 Context를 선택할 수 있습니다.
1) Application Context
Application Context는 애플리케이션의 전체 생명주기와 연결된 Context입니다. 앱이 실행되는 동안 항상 존재하며, 애플리케이션 전체에 관련된 작업을 수행할 때 사용됩니다.
(1) 주요 특징
① 앱이 실행되는 동안 항상 유지됨
② Activity나 Service의 생명주기와 무관하게 사용 가능
③ getApplicationContext()를 통해 어디서든 접근 가능
④ 싱글톤 패턴으로 앱 전체에 하나만 존재
⑤ UI 테마가 적용되지 않음
(2) 사용 예시
// Java
Context appContext = getApplicationContext();
// Kotlin
val appContext = applicationContext
Context appContext = getApplicationContext();
// Kotlin
val appContext = applicationContext
. . . . .
2) Activity Context
Activity Context는 특정 Activity의 생명주기와 연결된 Context입니다. UI 작업이나 현재 Activity와 관련된 작업을 수행할 때 사용됩니다.
(1) 주요 특징
① 해당 Activity의 생명주기와 동일하게 유지됨
② Activity 내에서는 this로 접근 가능
③ UI 관련 작업에 적합
④ 테마와 스타일이 적용됨
⑤ Activity가 종료되면 함께 소멸됨
(2) 사용 예시
// Java - Activity 내부에서
Context activityContext = this;
// 또는
Context activityContext = MainActivity.this;
// Kotlin - Activity 내부에서
val activityContext = this
// 또는
val activityContext = this@MainActivity
Context activityContext = this;
// 또는
Context activityContext = MainActivity.this;
// Kotlin - Activity 내부에서
val activityContext = this
// 또는
val activityContext = this@MainActivity
. . . . .
3) Context 종류별 사용 가이드
작업의 성격에 따라 적절한 Context를 선택하는 것이 매우 중요합니다. 아래 표는 상황별로 어떤 Context를 사용해야 하는지 정리한 것입니다.
| 작업 유형 | Application Context | Activity Context |
|---|---|---|
| 다이얼로그 표시 | ❌ | ✅ |
| Activity 시작 | ✅ (FLAG_NEW_TASK 필요) | ✅ |
| 레이아웃 인플레이션 | ❌ (테마 적용 X) | ✅ |
| 토스트 표시 | ✅ | ✅ |
| BroadcastReceiver 등록 | ✅ | ✅ |
| 리소스 접근 | ✅ | ✅ |
| SharedPreferences | ✅ | ✅ |
| 시스템 서비스 사용 | ✅ | ✅ |
일반적인 권장사항은 다음과 같습니다.
| 작업 | 권장 Context | 설명 |
|---|---|---|
| Toast 표시 | Application | 어디서든 사용 가능 |
| 새 Activity 시작 | Activity | UI 흐름 유지를 위해 |
| 레이아웃 인플레이션 | Activity | 테마와 스타일 적용을 위해 |
| 시스템 서비스 사용 | Application | 생명주기가 긴 작업에 |
| SharedPreferences | Application | 앱 전체에서 공유되는 데이터에 |
| 데이터베이스 접근 | Application | 백그라운드 작업에 |
#3. Context 사용 시 주의사항과 메모리 누수
Context를 잘못 사용하면 메모리 누수(Memory Leak)가 발생할 수 있습니다. 특히 Activity Context를 부적절하게 저장하거나 참조하면 Activity가 종료된 후에도 메모리에 남아있어 심각한 문제를 일으킬 수 있습니다.
1) 메모리 누수의 주요 원인
메모리 누수는 주로 Activity Context를 오래 살아있는 객체에 저장할 때 발생합니다. Activity가 종료되어도 다른 객체가 해당 Activity의 참조를 계속 유지하면, 가비지 컬렉터가 Activity를 회수할 수 없어 메모리 누수가 발생합니다.
(1) 잘못된 예시 - 정적 변수에 Context 저장
// 메모리 누수 위험이 있는 코드
public class MyManager {
private static Context context;
public static void init(Context context) {
// Activity Context를 정적 변수에 저장 - 위험!
MyManager.context = context;
}
}
public class MyManager {
private static Context context;
public static void init(Context context) {
// Activity Context를 정적 변수에 저장 - 위험!
MyManager.context = context;
}
}
위 코드의 문제점은 Activity Context가 정적 변수에 저장되어 Activity가 종료된 후에도 계속 참조가 유지된다는 것입니다. 이로 인해 Activity가 메모리에서 해제되지 않고 계속 남아있게 됩니다.
(2) 개선된 예시 - Application Context 사용
// 메모리 누수를 방지하는 코드
public class MyManager {
private static Context context;
public static void init(Context context) {
// Activity가 아닌 Application Context 사용
MyManager.context = context.getApplicationContext();
}
}
public class MyManager {
private static Context context;
public static void init(Context context) {
// Activity가 아닌 Application Context 사용
MyManager.context = context.getApplicationContext();
}
}
. . . . .
2) WeakReference를 활용한 메모리 누수 방지
Activity Context를 반드시 사용해야 하는 경우에는 WeakReference를 사용하여 메모리 누수를 방지할 수 있습니다. WeakReference는 가비지 컬렉터가 필요시 해당 객체를 회수할 수 있도록 허용합니다.
// WeakReference 사용하기
public class MyManager {
private WeakReference<Context> contextRef;
public MyManager(Context context) {
this.contextRef = new WeakReference<>(context);
}
public void doSomething() {
Context context = contextRef.get();
if (context != null) {
// context 사용
Toast.makeText(context, "Hello", Toast.LENGTH_SHORT).show();
}
}
}
public class MyManager {
private WeakReference<Context> contextRef;
public MyManager(Context context) {
this.contextRef = new WeakReference<>(context);
}
public void doSomething() {
Context context = contextRef.get();
if (context != null) {
// context 사용
Toast.makeText(context, "Hello", Toast.LENGTH_SHORT).show();
}
}
}
. . . . .
3) Context 관련 자주 발생하는 오류와 해결법
(1) IllegalStateException: Not allowed to start activity
Application Context로 새 Activity를 시작하려고 할 때 발생하는 오류입니다. Application Context로 Activity를 시작하려면 FLAG_ACTIVITY_NEW_TASK 플래그를 추가해야 합니다.
// Application Context로 Activity 시작 시
Intent intent = new Intent(context, SecondActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
Intent intent = new Intent(context, SecondActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
(2) BadTokenException: Unable to add window
Application Context로 Dialog를 표시하려고 할 때 발생합니다. Dialog는 반드시 Activity Context를 사용해야 합니다.
// 항상 Activity Context 사용하기
AlertDialog.Builder builder = new AlertDialog.Builder(activityContext);
builder.setTitle("제목")
.setMessage("내용")
.setPositiveButton("확인", null)
.show();
AlertDialog.Builder builder = new AlertDialog.Builder(activityContext);
builder.setTitle("제목")
.setMessage("내용")
.setPositiveButton("확인", null)
.show();
(3) Context 유효성 검사
비동기 작업 중에 Activity가 종료될 수 있으므로, Context 사용 전에 유효성을 검사하는 것이 좋습니다.
private boolean isContextValid() {
if (context instanceof Activity) {
Activity activity = (Activity) context;
return !activity.isFinishing() && !activity.isDestroyed();
}
return context != null;
}
if (context instanceof Activity) {
Activity activity = (Activity) context;
return !activity.isFinishing() && !activity.isDestroyed();
}
return context != null;
}
#4. 실전 Context 활용 예제
실무에서 자주 사용하는 Context 활용 패턴들을 코드와 함께 살펴보겠습니다. 각 예제는 실제 프로젝트에서 바로 적용할 수 있는 실용적인 내용들입니다.
1) 리소스 접근하기
문자열, 이미지, 색상 등의 리소스에 접근할 때 Context를 사용합니다.
// 문자열 리소스 접근
String appName = context.getString(R.string.app_name);
// 색상 리소스 접근
int color = context.getColor(R.color.colorPrimary);
// 드로어블 리소스 접근
Drawable icon = context.getDrawable(R.drawable.ic_launcher);
// 레이아웃 인플레이션
LayoutInflater inflater = LayoutInflater.from(context);
View view = inflater.inflate(R.layout.item_layout, parent, false);
String appName = context.getString(R.string.app_name);
// 색상 리소스 접근
int color = context.getColor(R.color.colorPrimary);
// 드로어블 리소스 접근
Drawable icon = context.getDrawable(R.drawable.ic_launcher);
// 레이아웃 인플레이션
LayoutInflater inflater = LayoutInflater.from(context);
View view = inflater.inflate(R.layout.item_layout, parent, false);
. . . . .
2) 시스템 서비스 사용하기
Android 시스템이 제공하는 다양한 서비스는 Context를 통해 접근합니다.
// 위치 서비스 접근
LocationManager locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
// 알림 서비스 접근
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
// 알람 서비스 접근
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
// 클립보드 서비스 접근
ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
LocationManager locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
// 알림 서비스 접근
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
// 알람 서비스 접근
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
// 클립보드 서비스 접근
ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
. . . . .
3) SharedPreferences 사용하기
간단한 데이터를 저장하고 불러올 때 SharedPreferences를 사용합니다.
// SharedPreferences 데이터 저장
SharedPreferences preferences = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE);
SharedPreferences.Editor editor = preferences.edit();
editor.putString("username", "user123");
editor.putBoolean("isLoggedIn", true);
editor.apply();
// SharedPreferences 데이터 읽기
SharedPreferences preferences = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE);
String username = preferences.getString("username", "");
boolean isLoggedIn = preferences.getBoolean("isLoggedIn", false);
SharedPreferences preferences = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE);
SharedPreferences.Editor editor = preferences.edit();
editor.putString("username", "user123");
editor.putBoolean("isLoggedIn", true);
editor.apply();
// SharedPreferences 데이터 읽기
SharedPreferences preferences = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE);
String username = preferences.getString("username", "");
boolean isLoggedIn = preferences.getBoolean("isLoggedIn", false);
. . . . .
4) Fragment에서 Context 사용하기
Fragment는 Context를 직접 상속받지 않지만, 연결된 Activity의 Context에 접근할 수 있습니다.
public class MyFragment extends Fragment {
private Context context;
@Override
public void onAttach(Context context) {
super.onAttach(context);
this.context = context;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
// Context 사용 예시
Toast.makeText(context, "Fragment Created", Toast.LENGTH_SHORT).show();
// 또는 getContext() 메서드 사용
if (getContext() != null) {
Toast.makeText(getContext(), "Fragment Created", Toast.LENGTH_SHORT).show();
}
}
}
private Context context;
@Override
public void onAttach(Context context) {
super.onAttach(context);
this.context = context;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
// Context 사용 예시
Toast.makeText(context, "Fragment Created", Toast.LENGTH_SHORT).show();
// 또는 getContext() 메서드 사용
if (getContext() != null) {
Toast.makeText(getContext(), "Fragment Created", Toast.LENGTH_SHORT).show();
}
}
}
. . . . .
5) Custom View에서의 Context 활용
커스텀 뷰를 만들 때 Context는 필수적으로 사용됩니다. 생성자에서 Context를 받아 리소스 초기화와 스타일 적용에 활용합니다.
public class MyCustomView extends View {
private Paint paint;
public MyCustomView(Context context) {
super(context);
init(context);
}
public MyCustomView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
public MyCustomView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
private void init(Context context) {
paint = new Paint();
paint.setColor(context.getColor(R.color.colorAccent));
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 그리기 로직
}
}
private Paint paint;
public MyCustomView(Context context) {
super(context);
init(context);
}
public MyCustomView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
public MyCustomView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
private void init(Context context) {
paint = new Paint();
paint.setColor(context.getColor(R.color.colorAccent));
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 그리기 로직
}
}
#5. 자주 묻는 질문 (FAQ)
1) Q: Application Context와 Activity Context는 언제 사용해야 하나요?
Application Context는 앱의 전체 생명주기 동안 유지되어야 하는 작업에 사용합니다. 싱글톤 객체 초기화, 데이터베이스 접근, SharedPreferences, 시스템 서비스 사용 등이 해당됩니다. Activity Context는 UI 관련 작업에 사용합니다. Dialog 표시, 레이아웃 인플레이션, 테마가 필요한 작업 등이 해당됩니다. 간단히 말하면 UI와 관련되면 Activity Context, 그 외에는 Application Context를 사용하는 것이 안전합니다.
. . . . .
2) Q: getContext()와 getApplicationContext()의 차이는 무엇인가요?
getContext()는 현재 컴포넌트의 Context를 반환합니다. Activity에서 호출하면 Activity Context를, Fragment에서 호출하면 연결된 Activity의 Context를 반환합니다. getApplicationContext()는 항상 Application Context를 반환합니다. 어디서 호출하든 동일한 Application 객체를 반환하므로 메모리 누수 걱정 없이 오래 유지할 수 있습니다.
. . . . .
3) Q: Context를 static 변수에 저장하면 안 되나요?
Activity Context를 static 변수에 저장하면 절대 안 됩니다. Activity가 종료되어도 static 변수는 계속 Activity를 참조하므로 메모리 누수가 발생합니다. 만약 static 변수에 Context를 저장해야 한다면 반드시 Application Context를 사용하거나, WeakReference로 감싸서 저장해야 합니다.
. . . . .
4) Q: Toast는 어떤 Context를 사용해야 하나요?
Toast는 Application Context와 Activity Context 모두 사용 가능합니다. Toast는 시스템 레벨에서 표시되므로 Activity의 생명주기에 영향을 받지 않습니다. 하지만 일반적으로는 코드가 실행되는 위치의 Context를 사용하는 것이 자연스럽습니다. Activity 내부라면 this를, Service나 다른 곳에서는 applicationContext를 사용하면 됩니다.
. . . . .
5) Q: Fragment에서 Context가 null이 될 수 있나요?
네, Fragment의 getContext()는 null을 반환할 수 있습니다. Fragment가 아직 Activity에 attach되지 않았거나, 이미 detach된 경우에 null이 반환됩니다. 따라서 Fragment에서 Context를 사용할 때는 항상 null 체크를 해야 합니다. onAttach()부터 onDetach() 사이에서만 안전하게 사용할 수 있습니다.
. . . . .
6) Q: ContextWrapper는 무엇인가요?
ContextWrapper는 Context를 감싸는(Wrapping) 클래스입니다. Context의 모든 메서드를 다른 Context에 위임하는 방식으로 동작합니다. Activity, Service, Application은 모두 ContextWrapper를 상속받습니다. ContextThemeWrapper는 ContextWrapper를 확장하여 테마 관련 기능을 추가한 클래스이며, Activity가 이를 상속받아 UI 테마를 지원합니다.
. . . . .
7) Q: Context 메모리 누수를 어떻게 감지하나요?
LeakCanary 라이브러리를 사용하면 메모리 누수를 자동으로 감지할 수 있습니다. build.gradle에 dependencies { debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7' }을 추가하면 디버그 빌드에서 자동으로 메모리 누수를 모니터링하고 알려줍니다. Android Studio의 Profiler를 사용하여 수동으로 메모리를 분석할 수도 있습니다.
. . . . .
8) Q: Application Context로는 왜 Dialog를 띄울 수 없나요?
Dialog는 Window 토큰이 필요한데, Window 토큰은 Activity에만 존재합니다. Application Context에는 Window 토큰이 없어서 Dialog를 띄우려고 하면 BadTokenException이 발생합니다. Dialog는 반드시 Activity의 Window에 연결되어야 하므로 Activity Context를 사용해야 합니다. 이는 AlertDialog, DatePickerDialog 등 모든 종류의 Dialog에 동일하게 적용됩니다.
. . . . .
9) Q: BroadcastReceiver의 onReceive()에서 받은 Context는 어떤 종류인가요?
BroadcastReceiver의 onReceive(Context context, Intent intent)에서 받은 Context는 ReceiverRestrictedContext라는 특수한 Context입니다. 이 Context는 registerReceiver()와 bindService() 메서드가 제한되어 있습니다. 일반적인 작업은 가능하지만, 오래 실행되는 작업에는 적합하지 않으므로 필요시 Application Context를 얻어서 사용하는 것이 좋습니다.
. . . . .
10) Q: Kotlin에서 Context 확장 함수를 만들 수 있나요?
네, Kotlin에서는 Context 확장 함수를 만들어 편리하게 사용할 수 있습니다. 예를 들어 fun Context.showToast(message: String) { Toast.makeText(this, message, Toast.LENGTH_SHORT).show() }와 같이 정의하면, context.showToast("메시지")처럼 간단하게 호출할 수 있습니다. 이는 코드의 가독성을 높이고 재사용성을 향상시키는 좋은 패턴입니다.
마무리
Context는 Android 앱 개발의 가장 기본이 되는 개념입니다. 이 글에서 다룬 내용을 정리하면 다음과 같습니다.
① Context는 앱의 환경 정보와 시스템 리소스에 접근할 수 있는 인터페이스를 제공하는 추상 클래스입니다.
② Application Context는 앱 전체 생명주기 동안 유지되며, Activity Context는 특정 Activity의 생명주기와 연결됩니다.
③ UI 관련 작업(Dialog, 레이아웃 인플레이션)은 Activity Context를 사용하고, 그 외 대부분의 작업은 Application Context를 사용하는 것이 안전합니다.
④ Activity Context를 정적 변수나 싱글톤에 저장하면 메모리 누수가 발생할 수 있으므로, Application Context를 사용하거나 WeakReference로 감싸야 합니다.
⑤ Fragment에서는 getContext()가 null을 반환할 수 있으므로 항상 null 체크가 필요하며, onAttach()부터 onDetach() 사이에서만 안전하게 사용할 수 있습니다.
Context를 올바르게 이해하고 사용하면 메모리 누수를 방지하고 안정적인 앱을 개발할 수 있습니다. 특히 초보 개발자라면 Application Context와 Activity Context의 차이를 명확히 이해하고, 상황에 맞는 Context를 선택하는 습관을 들이는 것이 중요합니다.
실무에서는 LeakCanary 같은 도구를 활용하여 메모리 누수를 조기에 발견하고, Context 사용 전에는 항상 유효성을 검사하는 것이 좋습니다. Android 개발에서 Context는 피할 수 없는 개념이므로, 이 글을 통해 정확히 이해하고 올바르게 활용하시기 바랍니다.
긴 글 읽어주셔서 감사합니다.
끝.
끝.
반응형
'Development > Android' 카테고리의 다른 글
| [Android] 키보드가 화면 레이아웃에 영향 주지 않게 하는 방법 (0) | 2020.04.08 |
|---|---|
| [Android] Android GC_CONCURRENT FREED 에러 해결 방법과 메모리 최적화 (0) | 2020.04.08 |
| [Android] Android Activity 생명주기와 실전 구현 방법 (기본) (0) | 2020.04.08 |
| [Android ] Android Intent Flag 완벽 가이드 - 화면 전환 제어 방법 (0) | 2020.04.08 |
| [Android] Android ArrayList 객체를 Intent로 전달하는 3가지 방법 (3) | 2020.04.08 |