하이브리드 앱 강좌 #3. 웹브라우저형 앱

하이브리드 앱의 유형

하이브리드 앱에 대한 개념이 모호한 이유 중 하나는 하이브리드 앱이라 부를 수 있는 앱의 종류가 워낙 다양하기 때문입니다.
웹 앱을 웹뷰를 통해 네이티브 앱 속에 포장한다는 개념은 공통적이지만, 포장할 웹 앱을 가져오는 방법에 따라서 하이브리드 앱을 크게 3가지로 나누어 볼 수 있습니다.
(아래에 쓰인 용어들는 편의상 제가 자의적으로 붙인 이름입니다.)

  1. 웹브라우저형 앱
  2. 전자책형 앱
  3. 하이브리드형 앱

웹브라우저형 앱

웹브라우저형 앱은 웹브라우저처럼 매번 특정 URL에 접속해서 웹 앱(웹페이지)을 불러옵니다.
웹 앱은 자신이 개발한 웹사이트를 참조할 수도 있고, 타 웹사이트를 참조할 수도 있습니다.
네이버 모바일 페이지를 참조하는 네이버 앱이 전자에 해당합니다.

네이버 모바일

DC인사이드를 참조하는 여러 브라우저 앱들은 후자에 해당합니다(예: 하이브리드 DC).

하이브리드 DC

이런 종류의 앱은 오프라인 상태에서는 아무런 기능을 할 수 없습니다.

오프라인

심지어 제대로 코딩되지 않은 경우에는 모바일 앱이 접속하려는 웹 앱의 URL이 그대로 노출되기도 합니다.

URL 노출

애플 앱스토어 검수에서는 웹브라우저형 앱이 단지 모바일 페이지를 불러오는 것에 그칠 경우, 앱으로 만들지 말고 웹사이트의 북마크를 추가하라고 합니다.
따라서 이런 앱은 네이티브 앱에서만 사용 가능한 기능들을(푸쉬 알림, 카메라 등) 추가해야 합니다.

전자책형 앱

전자책형 앱은 앱 설치 파일 내부에 이미 htm 파일들이 포함되어 있습니다.
웹뷰를 통해 로컬 htm 파일을 불러오므로 웹 서버에 접속할 필요가 없습니다.
따라서 서버의 트래픽을 소모하지 않고, 오프라인 상태에서도 작동하고, 각 페이지를 로딩하는 시간이 짧다는 장점이 있습니다.
컨텐츠 제공을 주된 목적으로 하는 몇몇 앱들이 해당됩니다(예: jQuery Reference)

jQuery Reference

단점으로는 하나의 페이지를 업데이트 하더라도 어플을 새로이 마켓에 업로드해야 한다는 점입니다.
안드로이드 앱의 경우에는 수 시간 안에 업데이트되지만, iOS 앱의 경우에는 또 다시 검수 과정을 거쳐야 하므로 하이브리드 앱의 장점 중 하나를 잃게 됩니다.

애플 앱스토어 검수에서는 이런 앱이 단지 정해진 컨텐츠를 불러오는 것에 그칠 경우, 앱으로 만들지 말고 전자책을 만들라고 합니다.
따라서 이런 앱은 사용자와 상호작용하는 기능을 추가해야 합니다.
인터넷을 사용하지 않는 순수한 전자책형 앱은 찾기 어렵고, 대개는 상호작용 기능을 추가하기 위해 웹뷰를 통해 게시판을 보여주는 방식으로 제작됩니다.

하이브리드형 앱

하이브리드형 앱은 웹브라우저형 앱과 전자책형 앱의 장점을 결합하고 단점은 보완한 것입니다.
일부 기능들은 전자책형 앱으로 구현하고 나머지 기능들은 웹브라우저형 앱으로 구현하는 경우가 대표적입니다.
공통 자바스크립트, 이미지 파일 등은 앱 내부에서 불러오고, 나머지 컨텐츠는 웹을 통해서 가져오기도 합니다.
웹 앱에 처음 접속할 때에는 인터넷을 사용하지만, 다음부터는 캐시된 페이지를 보여주기도 합니다.

개요

이번 강좌에서는 하이브리드 앱의 유형 중 가장 단순한 웹브라우저형 앱을 제작하겠습니다.
앱의 이름은 `DC 도우미`이며, 이름대로 타 웹사이트(DC인사이드)를 참조하므로 따로 HTML, javascript 코딩은 하지 않습니다.
안드로이드 웹뷰 활용 강좌로 보아도 됩니다.

준비

앞 강좌에서 다룬 대로 이클립스에서 새 프로젝트를 생성합니다.
LinearLayout, WebView, Button을 배치하고 텍스트를 지정합니다.

레이아웃 배치

버튼 클릭 이벤트를 처리하기 위해 WebView와 Button에 id를 부여합니다.

id지정

기본 작업을 위한 소스는 다음과 같습니다.

public class MainActivity extends Activity {

	WebView webViewMain;
	Button buttonTitle, button1, button2, button3;
	OnClickListener cListener;

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_main);

		webViewMain = (WebView) findViewById(R.id.webViewMain);
		buttonTitle = (Button) findViewById(R.id.buttonTitle);
		button1 = (Button) findViewById(R.id.button1);
		button2 = (Button) findViewById(R.id.button2);
		button3 = (Button) findViewById(R.id.button3);
		cListener = new OnClickListener() {

			@Override
			public void onClick(View v) {
				// TODO Auto-generated method stub
				switch (v.getId()) {
				case R.id.buttonTitle:
					buttonTitleClick();
					break;
				case R.id.button1:
					button1Click();
					break;
				case R.id.button2:
					button2Click();
					break;
				case R.id.button3:
					button3Click();
					break;
				default:
					break;
				}
			}
		};
		buttonTitle.setOnClickListener(cListener);
		button1.setOnClickListener(cListener);
		button2.setOnClickListener(cListener);
		button3.setOnClickListener(cListener);
		
	}

	@Override
	public boolean onCreateOptionsMenu(Menu menu) { ... }

	void buttonTitleClick() { }

	void button1Click() { }

	void button2Click() { }

	void button3Click() { }
}

인터넷 퍼미션 추가

웹에 접속해야 하므로 `인터넷` 퍼미션을 사용해야 합니다.
이를 추가하기 위해서는 Project Explorer에서 AndroidManifest.xml을 더블클릭합니다.

퍼미션

Manifest 창 아래에서 Permission 탭을 선택합니다.

퍼미션

Add 버튼을 클릭합니다.

퍼미션

이 중에서 Uses Permission을 선택하고 OK를 클릭합니다.

퍼미션

Name 옆의 셀렉트 박스에서 android.permission.INTERNET 을 선택합니다.
Ctrl+S를 눌러 저장하면 반영된 것을 볼 수 있습니다.

이제 이 어플을 설치하려 하면 `네트워크 통신 – 완전한 네트워크 액세스`라는 권한이 필요하다는 메시지를 볼 수 있게 됩니다.
이러한 단계를 거치지 않은 경우, 인터넷이 되는 상황에서도 웹뷰에서는 웹페이지를 표시할 수 없다는 메시지만 나옵니다.

URL 불러오기

지금 상태에서 어플을 실행시킬 경우 웹뷰에서는 빈 화면만 볼 수 있습니다.
어플 실행이 마친 뒤에는 웹뷰에 홈페이지를 불러와야 합니다.
onCreate 메서드에 다음과 같은 코드를 추가하기만 하면 됩니다.

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		...
		
		webViewMain.loadUrl("http://http://m.dcinside.com/list.php?id=programming");
	}

WebSettings

첫실행

이제 어플을 실행하여 작동해 보면 몇 가지 이상한 점들이 눈에 뜨입니다.
우선 자바스크립트가 작동하지 않습니다.
또한 페이지 줌인/줌아웃이 작동하지 않습니다.
이러한 문제점들을 해결하기 위해서는 WebSettings를 이용해야 합니다.

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		...
		
		WebSettings webSettings = webViewMain.getSettings();
		webSettings.setJavaScriptEnabled(true);
		webSettings.setBuiltInZoomControls(true);
		webViewMain.loadUrl("http://http://m.dcinside.com/list.php?id=programming");
	}

setJavaScriptEnabled 메서드를 통해 javascript 실행을 허용할 수 있습니다.
setBuiltInZoomControls 메서드를 통해 화면 확대/축소를 허용할 수 있습니다.
그 이외에도 확대 축소를 할 때에 웹뷰 오른쪽 아래에 나타나는 돋보기 아이콘을 없애기 위해서는 setDisplayZoomControls(false)를 추가하면 됩니다.

WebViewClient 지정

현재 상태의 또 다른 문제점 중 하나는 링크를 클릭하면 웹뷰 안에서 열리지 않고 `작업을 수행할 때 사용하는 애플리케이션`을 선택하라고 하는 것입니다.

링크클릭

이러한 문제점은 WebView에 WebViewClient가 지정되어 있지 않기 때문입니다.
WebViewClient는 페이지 로딩 시작과 끝, 키입력 등의 여러 이벤트를 처리할 수 있도록 도와줍니다.
따라서 다음과 같은 코드를 추가하여 WebViewClient를 지정하면 WebView가 링크 클릭을 처리할 수 있게 됩니다.

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		...
		
		WebSettings webSettings = webViewMain.getSettings();
		webSettings.setJavaScriptEnabled(true);
		webSettings.setBuiltInZoomControls(true);
		
		webViewMain.setWebViewClient(new WebViewClient() {
			
		});
		
		webViewMain.loadUrl("http://m.dcinside.com/list.php?id=programming");
	}

WebChromeClient 지정

또 다른 문제점은 My 갤러리 메뉴를 클릭하면 `로그인 후 이용가능합니다. 로그인하시겠습니까?` 라는 대화상자가 표시되어야 하지만 아무런 반응이 없다는 것입니다.
이러한 문제점은 WebView에 WebChromeClient가 지정되어 있지 않기 때문입니다.
WebChromeClient는 dialog, favicon, title, progress를 다루도록 도와줍니다.
따라서 다음과 같은 코드를 추가하여 WebChromeClient를 지정하면 WebView가 대화상자를 출력할 수 있게 됩니다.

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		...
		
		WebSettings webSettings = webViewMain.getSettings();
		webSettings.setJavaScriptEnabled(true);
		webSettings.setBuiltInZoomControls(true);
		
		webViewMain.setWebViewClient(new WebViewClient() {
			
		});
		
		webViewMain.setWebChromeClient(new WebChromeClient() {
			
		});
		
		webViewMain.loadUrl("http://m.dcinside.com/list.php?id=programming");
	}

Back 버튼 눌러서 뒤로가기

또 다른 문제점은 Back 버튼을 누르면 곧바로 어플이 종료된다는 점입니다.
onBackPressed 메서드를 재정의하여 webViewMain에서 뒤로가기가 가능할 경우 어플을 종료하지 않고 웹페이지 뒤로가기 기능을 실행하도록 하겠습니다.
메서드를 재정의할 때에는 직접 타이핑하는 것이 아니라 적절한 위치에 커서를 위치하고 Alt+Shift+S를 누르면 다음과 같은 컨텍스트 메뉴가 나옵니다.

자동완성 메뉴

여기에서 Override/Implement Methods 메뉴를 선택하면 다음과 같은 창이 나옵니다.

메서드 재정의 또는 구현

목록에서 onBackPressed를 선택하고 OK 버튼을 클릭하면 커서가 있던 위치에 다음과 같은 소스가 자동삽입됩니다.

public class MainActivity extends Activity {
	...

	@Override
	public void onBackPressed() {
		// TODO Auto-generated method stub
		super.onBackPressed();
	}

	...
}

앞에서 말한 기능을 구현하기 위해서는 onBackPressed 메서드 안에 다음과 같이 코드를 수정/추가하면 됩니다.

public class MainActivity extends Activity {
	...

	@Override
	public void onBackPressed() {
		// TODO Auto-generated method stub
		//super.onBackPressed();
		if (webViewMain.canGoBack()) {
			webViewMain.goBack();
		} else {
			finish();
		}
	}

	...
}

액션바 숨기기

또 다른 불편한 점은 불필요한 액션바가 큰 공간을 차지하고 있다는 것입니다.
이것은 어플의 테마를 수정하여 해결할 수 있습니다.
AndroidManifest.xml 파일을 연 뒤, Application 탭을 선택합니다.

테마 설정

Theme 옆의 Browse 버튼을 클릭합니다.

테마 설정

Resource Chooser 창에서 System Resources를 선택하고, 텍스트칸에 theme.light.not 정도를 입력하면 `Theme.Light.NoTitleBar`가 보입니다.
선택하고 OK 버튼을 누른 뒤 Ctrl+S를 눌러 저장하고 실행하면 다음과 같은 결과를 얻을 수 있습니다.

테마 설정

로딩 관련 처리

링크를 클릭하여 웹뷰에서 로딩이 시작되면 상단 버튼의 텍스트를 `로딩 중…`으로 바꾸고, 로딩이 완료되면 해당 웹페이지의 타이틀을 상단 버튼의 텍스트로 지정합니다.
이를 위해서는 앞서 웹뷰에 지정한 WebViewClient의 소스를 수정해야 합니다.
onCreate 메서드에서 다음과 같이 되어 있었던 원래의 소스에서,

		webViewMain.setWebViewClient(new WebViewClient() {

		});

자동완성 기능을 이용하여 onPageStarted, onPageFinished 메서드를 추가해서 다음과 같이 수정합니다.

		webViewMain.setWebViewClient(new WebViewClient() {

			@Override
			public void onPageStarted(WebView view, String url, Bitmap favicon) {
				// TODO Auto-generated method stub
				super.onPageStarted(view, url, favicon);
			}
			
			@Override
			public void onPageFinished(WebView view, String url) {
				// TODO Auto-generated method stub
				super.onPageFinished(view, url);
			}
			
		});

onPageStarted 메서드에서는 상단 버튼의 텍스트를 `로딩 중…`으로 변경합니다.

			@Override
			public void onPageStarted(WebView view, String url, Bitmap favicon) {
				// TODO Auto-generated method stub
				super.onPageStarted(view, url, favicon);
				buttonTitle.setText("로딩 중...");
			}

onPageFinished 메서드에서는 상단 버튼의 텍스트를 웹뷰에 로딩된 페이지의 타이틀로 변경합니다.

			@Override
			public void onPageFinished(WebView view, String url) {
				// TODO Auto-generated method stub
				super.onPageFinished(view, url);
				buttonTitle.setText(webViewMain.getTitle());
			}

버튼 클릭 이벤트 처리

글쓰기 버튼은 button2이고, 앞에서 이를 클릭하면 button2Click 메서드가 실행되도록 코딩했습니다.
이 버튼을 클릭하면 글쓰기 링크로 연결되도록 합니다.
방법은 onCreate에서 홈페이지를 불러올 때와 동일합니다.

	void button2Click() {
		webViewMain.loadUrl("http://m.dcinside.com/write.php?id=programming&mode=write");
	}

새로고침 버튼은 button3이고, 앞에서 이를 클릭하면 button3Click 메서드가 실행되도록 코딩했습니다.
이 버튼을 클릭하면 자바스크립트 함수를 호출해야 합니다.
loadUrl 메서드를 사용한다는 점은 동일하지만, 앞에 `javascript:`를 붙여야 한다는 점이 다릅니다.

	void button3Click() {
		webViewMain.loadUrl("javascript:location.reload();");
	}

이를 응용하면 메시지 창을 띄우거나, 특정 element의 스타일을 변경하는 작업이 가능합니다.
예를 들어 button1Click 메서드를 다음과 같이 코딩하고 실행한 뒤 button1을 클릭하면 다음과 같이 특정 div를 감출 수 있습니다.

	void button1Click() {
		webViewMain.loadUrl("javascript:document.getElementsByTagName('div')[2].style.display='none';");
	}

실행 후 버튼을 클릭한 결과는 다음과 같습니다.

javascript 실행

이런 방법은 무궁무진하게 활용될 수 있지만, 이를 자세히 다루는 것은 본 강좌의 범위를 벗어납니다.
보다 자세한 내용을 알기 위해서는 HTML5, CSS, jQuery 등에 관한 좋은 강좌들을 참고하세요.

이런 과정으로 작성된 어플은 다음 링크에서 설치할 수 있습니다(구글 플레이 스토어).

DC 도우미

이상으로 웹브라우저형 하이브리드 앱 개발에 대해 예제를 통해 간단히 다루어 보았습니다.
다음 강좌에서는 전자책형 하이브리드 앱 개발 방법을 다루겠습니다.

관련 포스트

트위터 전광판 페이지 개발

예전에 사용하다가 방치해 둔 블로그에 새 댓글이 달렸다는 알림 메일이 왔습니다.
이전에 facebook open api를 다룬 포스팅을 남긴 적이 있는데, 그 글의 댓글로 open api를 사용한 단기 알바를 해 보겠냐는 제의가 들어왔던 것이었습니다.
작업할 시간은 있었지만 굳이 일을 해야 할 필요성을 느끼지 못해서 처음에는 그냥 지나쳐 버렸습니다.
그렇지만 처음으로 open api를 사용한 웹개발을 해볼 수 있다는 설렘을 이기지 못하고, 결국 댓글을 남긴 분께 연락을 드렸습니다.

작업할 내용은 ASP 환경에서 twitter open api를 사용하여 특정 사용자(들)의 트윗을 가져와서 웹브라우저 상에 출력하는 일이었습니다.
제가 주력으로 사용하는 언어인 PHP를 사용할 수 없다는 점이 걸리기는 했지만, javascript api를 사용하면 해결될 것이라고 생각하고 작업을 시작했습니다.

계획은 제가 ASP 프로그래밍을 배운 적이 없기 때문에 순수 javascript로 작업하는 것이었습니다.
thread, jQuery를 사용하여 일정 간격으로 트위터 서버로부터 트윗을 가져오고
템플릿 파일을 읽어 특정 문자열을 트윗 데이터로 치환하도록 구현했습니다.

문제점 #1. cross site 보안 문제
client(웹페이지)에서는 보안 문제로 인해 몇 가지 예외를 제외하고는 다른 도메인에 접근할 수 없습니다.
대표적인 예외가 img 태그입니다.
이런 원칙에 따라 javascript 상에서도 트위터 api 서버로(즉, 다른 도메인으로) ajax 호출을 할 수 없습니다.
이를 해결하기 위해 JSONP를 사용하였습니다.
JSONP는 client에서 다른 도메인으로 ajax 호출을 할 수 있도록 합니다.
다만 그 다른 도메인의 페이지에서 반환하는 값이 json 형식이어야 합니다.

문제점 #2. ajax error 처리 문제
JSONP를 사용하여 다른 도메인으로 ajax 호출은 할 경우, 호출이 success한 경우의 콜백 메서드는 잘 작동하지만, 호출이 실패한(error) 경우에는 콜백 메서드가 실행되지 않는 문제점이 있습니다.
이를 해결하기 위해 timer를 사용하였습니다.
ajax를 호출할 때에 setTimeout()으로 에러처리 콜백 메서드를 호출할 timer를 만들어 둔 뒤, 호출이 success 한 경우에는 clearTimeout()을 통해 timer를 해제하도록 만들었습니다.

문제점 #3. rate limiting 문제
트위터 서버에는 api 호출에 rate limiting이 있습니다.
인증받지 않은 호출은 1시간에 150회, 인증받은 호출은 1시간에 350회까지 가능합니다.
특정 트위터 계정들의 트윗을 가져오기 위해 각각의 특정 트위터 계정에 대해 api 호출을 하면 금세 인증 횟수 제한에 걸립니다.
이를 해결하기 위해 별도의 트위터 계정을 생성하고 그 계정이 특정 계정들을 follow하도록 하고, 웹페이지에서는 생성한 트위터 계정의 timeline을 가져오도록 하려면 oauth 인증이 필요하므로 javascript만으로는 구현하기 어렵거나 복잡해집니다.
이를 해결하기 위한 방법으로 별도의 트위터 계정을 생성하고, 그 계정에 list를 추가한 뒤, 특정 트위터 계정들을 그 list에 추가하는 방법을 택했습니다.
list의 timeline을 가져올 때에는 인증이 불필요한데다 list에 대해 한 번 api를 호출하면 list에 속한 모든 트위터 계정들의 트윗을 가져오므로 rate limiting에도 걸리지 않게 됩니다.

문제점 #4. 이미지 출력 문제
트위터에서는 얼마 전부터 트윗에 이미지 첨부 기능을 제공합니다.
트윗을 가져오는 api를 호출하면 entities라는 태그 내에 이미지에 대한 url을 포함시키고 있는 것을 볼 수 있습니다.
그러나 아직 많은 사용자들은 yfrog나 twitpic을 사용하고 있기 때문에 이런 경우에도 웹페이지에서 곧바로 이미지를 출력해주기 위해서는 다른 도메인에 접속해서 parsing하는 부분이 필요합니다.
앞서 언급한 대로 javascript에서는 다른 도메인에 접속할 수 없기 때문에 javascript로만 해결하기는 어렵습니다.
이를 해결하기 위해 어쩔 수 없이 asp.net를 사용해서 코딩해야 했습니다.
yfrog.com이나 twitpic에 대한 링크를 별도로 만든 .aspx 파일에 전달하면 이 파일에서 그 링크에 접속하여 parsing을 한 뒤에 image 파일의 경로를 반환하도록 했습니다.

문제점 #5. 날짜 인식 문제
트위터에서 api 호출에 대해 반환한 json 객체 내의 날짜 표기 형식을 javascript의 Date 객체 생성에 곧바로 이용할 수 없었습니다.
이를 해결하기 위해 substring() 메서드를 사용하여 Date 객체 생성에 이용했습니다.

문제점 #6. json 객체 내의 null 문제
json 객체 내에 entities나 media 태그가 없는지 판단하는 과정에서 자꾸만 null 에러가 발생하면서 작동이 멈추었습니다.
매번 객체가 null이나 undefined 인지 판단하기도 어려웠을 뿐 아니라 코드가 길어지고 어려워졌습니다.
이를 해결하기 위해 try~catch 를 사용하여, entities나 media 태그가 없는 경우 catch 에서 처리하도록 했습니다.

문제점 #7. 다른 환경에서 실행되지 않는 문제
제 컴퓨터에서 개발할 때에는 .aspx 파일 실행이 잘 되었지만, 의뢰인의 서버에서는 잘 실행되지 않았습니다.
이를 해결하기 위해 publish 과정에서 생성된 Web.config, bin 폴더를 웹주소 루트에 위치시켰습니다.
또한 웹서버에서 asp.net의 버전을 동일하게 맞추어 준 뒤에는 문제 없이 실행되었습니다

문제점 #8. Chrome에서 실행되지 않는 문제
인터넷 익스플로러에서는 잘 실행되었지만 Chrome 브라우저에서는 제대로 작동하지 않았습니다.
개발자 도구로 에러나는 부분을 찾아보니 Date 객체를 생성하는 부분에 오류가 있었습니다.
new Date(“0000-00-00 00:00:00”)으로 Date 객체를 생성하였더니 IE에서는 문제가 없었지만 Chrome에서는 에러를 발생시켰습니다.
이를 해결하기 위해 new Date(“2001-01-01 00:00:00”)으로 Date 객체를 생성하였더니 Chrome에서는 잘 인식했지만 인터넷 익스플로러에서는 에러를 발생시켰습니다.
결국 new Date(2001, 1, 1)을 사용하니 두 브라우저에서 모두 이상 없이 작동했습니다.

문제점 #9. javascript 정규식 패턴 문제
트윗을 출력할 때에 템플릿에 맞추어 출력하는 과정에서 replace() 메서드를 사용하였습니다.
템플릿 파일 내에는 {screen_name}, {text} 등이 포함되어 있고, 이 부분을 찾아서 적절한 값으로 치환하는 방식을 사용했습니다.
data.replace(“{name}”, twit_item[“name”]) 과 같은 구문을 사용했는데, 가끔 제대로 작동하지 않는 등의 문제가 있었습니다.
이를 해결하기 위해 몇 시간이나 매달렸지만 제대로 되지 않았고, 결국 .aspx 파일을 ajax로 호출하여 GET으로 전달하는 방식을 사용하게 되었습니다.
나중에 알게 된 사실은, javascript에서 정규식 패턴은 “를 사용하여 감싸는 것이 아니라는 것이었습니다.
즉, 패턴에 “/      /im”라고 입력하면 슬래시 안쪽을 찾은 것이 아니라 쌍따옴표 안쪽을 찾는 것입니다.
원하는 결과를 얻기 위해서는 쌍따옴표 없이 /     /im을 사용했어야 합니다.

관련 포스트