이 기사에서는 까다로운 상황을 처리하는 데 필요한 Android SDK 도구 중 일부에 대해 다룬다. Android 애플리케이션을 개발하려면 JDK(Java Development Kit)가 필요한 최신 Android SDK가 필요하다. 필자는 Android 2.2 및 JDK 1.6.0_17을 사용했다(이러한 도구에 대한 링크는 참고자료 참조). 실제 장치는 없어도 된다. 이 기사에 있는 모든 코드는 SDK와 함께 제공되는 Android 에뮬레이터에서 정상적으로 실행된다. 이 기사에서는 기본적인 Android 개발에 대해서는 설명하지 않기 때문에 사용자는 Android 프로그래밍에 익숙해야 한다. 하지만 Java 프로그래밍 언어에 대한 지식이 있으면 이 기사의 내용을 이해할 수 있을 것이다.
Android 애플리케이션의 가장 일반적인 태스크 중 하나는 네트워크를 통해 데이터를 검색하거나 원격 서버로 전송하는 것이다. 이 조작을 수행하면 사용자에게 표시하기 원하는 일부 새로운 데이터가 생성되는 경우가 자주 있다. 이는 사용자 인터페이스를 수정해야 한다는 것을 의미한다. 대부분의 개발자는 사용자가 기본 UI 스레드에서 네트워크(특히 네트워크 연결 속도가 매우 느린 휴대전화)를 통해 데이터에 액세스하는 것과 같은 잠재적인 장기 실행 태스크를 수행해서는 안 된다는 것을 알고 있다. 이러한 장기 실행 태스크를 수행하면 해당 태스크가 완료될 때까지 애플리케이션이 멈춘다. 실제로 이 태스크를 수행하는 데 5초 넘게 걸리는 경우 Android 운영 체제는 그림 1에 있는 악명 높은 Application Not Responding
대화 상자를 표시한다.
그림 1. Android의 악명 높은 Application Not Responding 대화 상자
사용자의 네트워크 연결 속도가 얼마나 느릴지는 알 수 없다. 모험을 피하기 위해서는 이러한 태스크를 다른 스레드에서 수행해야 하거나 최소한 기본 UI 스레드에서는 수행해서는 안 된다. 대부분은 아니더라도 다수의 Android 애플리케이션은 복수의 스레드를 처리해야 하기 때문에 동시성을 처리해야 한다. 애플리케이션에는 데이터를 로컬로 유지해야 할 경우가 있는데 이 경우 Android의 로컬 데이터베이스가 매력적인 옵션이다. 이러한 세 가지 시나리오(다른 스레드, 동시성 및 로컬로 데이터 유지) 모두에는 Java 환경에서 이러한 사항을 수행하는 일부 표준 방식이 있다. 하지만 이 기사에서 알 수 있듯이 Android는 일부 다른 옵션을 제공한다. 이러한 각각의 옵션과 이러한 옵션의 장단점에 대해 살펴본다.
Java 프로그래밍에서 네트워크를 통해 호출을 작성하는 것은 간단하다. 익숙한 java.net
패키지에는 이를 수행하는 데 필요한 몇 가지 클래스가 포함되어 있다. 이러한 클래스는 대부분 Android에도 있으며 다른 Java 애플리케이션에서와 같이 실제로 java.net.URL
및 java.net.URLConnection
과 같은 클래스를 사용할 수 있다. 하지만 Android에는 Apache HttpClient 라이브러리가 포함되어 있다. 이 방식은 Android에서 네트워킹을 수행하기 위해 선호되는 방법이다. 일반적인 Java 클래스를 사용하는 경우라도 Android의 구현에서는 계속 HttpClient를 사용한다. Listing 1에서는 이 필수 라이브러리의 사용 예를 보여 준다. (모든 소스 코드는 다운로드를 참조한다.)
Listing 1. Android에서 Http Client 라이브러리 사용하기
private ArrayList<Stock> fetchStockData(Stock[] oldStocks) throws ClientProtocolException, IOException{ StringBuilder sb = new StringBuilder(); for (Stock stock : oldStocks){ sb.append(stock.getSymbol()); sb.append('+'); } sb.deleteCharAt(sb.length() - 1); String urlStr = "http://finance.yahoo.com/d/quotes.csv?f=sb2n&s=" + sb.toString(); HttpClient client = new DefaultHttpClient(); HttpGet request = new HttpGet(urlStr.toString()); HttpResponse response = client.execute(request); BufferedReader reader = new BufferedReader( new InputStreamReader(response.getEntity().getContent())); String line = reader.readLine(); int i = 0; ArrayList<Stock> newStocks = new ArrayList<Stock>(oldStocks.length); while (line != null){ String[] values = line.split(","); Stock stock = new Stock(oldStocks[i], oldStocks[i].getId()); stock.setCurrentPrice(Double.parseDouble(values[1])); stock.setName(values[2]); newStocks.add(stock); line = reader.readLine(); i++; } return newStocks; } |
이 코드는 Stock
오브젝트의 배열을 포함한다. 이러한 오브젝트는 사용자가 소유하는 주식에 대한 정보(예: 종목 기호, 주가 등)와 이 주식에 대해 사용자가 지불한 금액과 같은 보다 개인적인 정보를 함께 보유하는 기본적인 데이터 구조 오브젝트이다. HttpClient
클래스를 사용하여 Yahoo Finance에서 동적 데이터(예: 주식의 현재 가격)를 검색한다. HttpClient
는 HttpUriRequest
를 사용하며 이 경우에서 사용자는 HttpUriRequest
의 서브클래스인 HttpGet
을 사용한다. 이와 비슷하게 데이터를 원격 서버에 게시해야 하는 경우를 위한 HttpPost
클래스가 있다. 클라이언트로부터 HttpResponse
를 수신하면 응답의 기본 InputStream
에 액세스하여 버퍼링한 후 구문 분석하여 주식 데이터를 얻을 수 있다.
네트워크를 통해 데이터를 검색하는 방법을 살펴봤으니 이제는 이 데이터를 사용하여 멀티스레딩을 통해 Android UI를 효율적으로 업데이트하는 방법을 살펴본다.
Listing 1에 있는 코드를 애플리케이션의 기본 UI 스레드에서 실행하는 경우 사용자 네트워크의 속도에 따라 Application Not Responding
대화 상자가 나타날 수 있다. 따라서 스레드를 파생(spawn)시켜 이 데이터를 페치해야 한다. Listing 2에서는 이를 수행하는 한 가지 방법을 보여 준다.
Listing 2. 기본 멀티스레딩(작동하지 않으므로 수행하지 않음)
private void refreshStockData(){ Runnable task = new Runnable(){ public void run() { try { ArrayList<Stock> newStocks = fetchStockData(stocks.toArray( new Stock[stocks.size()])); for (int i=0;i<stocks.size();i++){ Stock s = stocks.get(i); s.setCurrentPrice( newStocks.get(i).getCurrentPrice()); s.setName(newStocks.get(i).getName()); refresh(); } } catch (Exception e) { Log.e("StockPortfolioViewStocks", "Exception getting stock data", e); } } }; Thread t = new Thread(task); t.start(); } |
Listing 2의 캡션은 기본 코드임을 나타내고 있으며 실제로도 그렇다. 이 간단한 예제에서는 Listing 1의 fetchStockData
메소드를 Runnable
오브젝트에서 랩핑한 후 새 스레드에서 실행하여 호출한다. 이 새 스레드에서는 둘러싸는 Activity
(UI를 작성하는 클래스)의 멤버 변수인 stocks
에 액세스한다. 이름이 나타내듯 이는 Stock
오브젝트의 데이터 구조이다(이 예에서는 java.util.ArrayList
). 달리 말하면 두 개의 스레드(기본 UI 스레드와 파생(spawn)된 스레드(Listing 2에 있는 코드에서 호출됨) 사이에서 데이터를 공유한다. 파생(spawn)된 스레드에서 공유 데이터를 수정하면 Activity
오브젝트에 대한 refresh
메소드를 호출하여 UI를 업데이트한다.
Java Swing 애플리케이션을 프로그래밍한 경우에는 이와 같은 패턴을 따랐을 것이다. 하지만 Android에서는 이 패턴이 작동하지 않는다. 파생(spawn)된 스레드는 UI를 전혀 수정할 수 없다. 그렇다면 UI를 멈추지 않고 데이터가 수신되면 UI를 수정할 수 있는 방식으로 데이터를 검색하려면 어떻게 해야 하는가? android.os.Handler
클래스를 사용하면 스레드 사이에서 조정하고 통신할 수 있다. Listing 3에서는 Handler
를 사용하는 업데이트된 refreshStockData
메소드를 보여 준다.
Listing 3.
Handler
를 사용하여 실제로 작동하는 멀티스레딩private void refreshStockData(){ final ArrayList<Stock> localStocks = new ArrayList<Stock>(stocks.size()); for (Stock stock : stocks){ localStocks.add(new Stock(stock, stock.getId())); } final Handler handler = new Handler(){ @Override public void handleMessage(Message msg) { for (int i=0;i<stocks.size();i++){ stocks.set(i, localStocks.get(i)); } refresh(); } }; Runnable task = new Runnable(){ public void run() { try { ArrayList<Stock> newStocks = fetchStockData(localStocks.toArray( new Stock[localStocks.size()])); for (int i=0;i<localStocks.size();i++){ Stock ns = newStocks.get(i); Stock ls = localStocks.get(i); ls.setName(ns.getName()); ls.setCurrentPrice(ns.getCurrentPrice()); } handler.sendEmptyMessage(RESULT_OK); } catch (Exception e) { Log.e("StockPortfolioViewStocks", "Exception getting stock data", e); } } }; Thread dataThread = new Thread(task); dataThread.start(); } |
Listing 2에 있는 코드와 Listing 3에 있는 코드 사이에는 두 가지 주요 차이점이 있다. 두드러진 차이점은 Handler
의 존재 여부이다. 두 번째 차이점은 파생(spawn)된 스레드에서 UI를 수정하지 않는다는 것이다. 대신 메시지를 Handler
에 전송하면 Handler
가 UI를 수정한다. 또한 이전과 같이 스레드에서 stocks
멤버 변수를 수정하지 않는다는 것에 유의한다. 대신 데이터의 로컬 사본을 수정한다. 이는 반드시 필요한 것은 아니지만 이 방법이 더 안전하다.
Listing 3에서는 동시 프로그래밍에서 매우 일반적인 패턴으로 판명된 방식인 데이터를 복사한 후 일부 장기 태스크를 수행하는 새 스레드에 전달하고 결과 데이터를 다시 기본 UI 스레드에 전달한 후 해당 데이터로 기본 UI 스레드를 업데이트하는 방식을 보여 준다. Handlers
는 Android에서 이를 위한 기본 통신 메커니즘이며 이 패턴의 구현을 더 용이하게 만든다. 하지만 Listing 3에는 여전히 상용구 코드가 상당히 포함되어 있다. 다행히도 Android는 이 상용구 코드 대부분을 캡슐화하여 제거할 수 있는 방법을 제공한다. Listing 4에서 이 방법을 보여 준다.
Listing 4.
AsyncTask
를 사용한 편리한 멀티스레딩private void refreshStockData() { new AsyncTask<Stock, Void, ArrayList<Stock>>(){ @Override protected void onPostExecute(ArrayList<Stock> result) { ViewStocks.this.stocks = result; refresh(); } @Override protected ArrayList<Stock> doInBackground(Stock... stocks){ try { return fetchStockData(stocks); } catch (Exception e) { Log.e("StockPortfolioViewStocks", "Exception getting stock data", e); } return null; } }.execute(stocks.toArray(new Stock[stocks.size()])); } |
여기서 알 수 있듯이 Listing 4에는 Listing 3보다 훨씬 적은 상용구가 포함되어 있다. 사용자는 스레드 또는 Handlers
를 작성하지 않는다. AsyncTask
를 사용하여 모두를 캡슐화한다. AsyncTask
를 작성하려면 doInBackground
메소드를 구현해야 한다. 이 메소드는 항상 별도의 스레드에서 실행되므로 자유롭게 장기 실행 태스크를 호출할 수 있다. 입력 유형은 작성되는 AsyncTask
의 유형 매개변수에서 제공된다. 이 경우에는 첫 번째 유형 매개변수가 Stock
이었기 때문에 doInBackground
에는 Stock
오브젝트의 배열이 전달된다. 마찬가지로 ArrayList<Stock>
이 AsyncTask
의 세 번째 유형 매개변수이기 때문에 리턴된다. 이 예제에서 필자는 onPostExecute
메소드도 대체하도록 선택했다. 이 메소드는 doInBackground
에서 다시 제공되는 데이터에 대해 수행해야 할 사항이 있는 경우 구현할 선택적 메소드이다. 이 메소드는 항상 기본 UI 스레드에서 실행되므로 UI를 수정하는 데 아주 적합하다.
AsyncTask
를 사용하면 멀티스레드 코드를 매우 단순화할 수 있다. AsyncTask는 개발 경로에서 다수의 동시성 위험을 제거한다. 하지만 AsyncTask
오브젝트에 있는 doInBackground
메소드가 실행되는 동안 장치에서 방향이 변경되면 발생하는 문제와 같은 AsyncTask
의 일부 잠재적 문제점을 여전히 찾을 수 있다. 이와 같은 경우를 처리하는 방법에 대한 일부 기술은 참고자료에 있는 링크를 참조한다.
이제 Android가 데이터베이스에 대한 일반적인 Java 작업 방식과 상당한 차이를 보이는 또다른 일반적인 태스크에 대해 살펴본다.
Android의 한 가지 매우 유용한 기능은 로컬 관계형 데이터베이스가 있다는 것이다. 물론 데이터를 로컬 파일에 저장할 수 있지만 RDBMS(Relational Database Management System)를 사용하여 데이터를 저장하는 것이 더 유용한 경우가 자주 발생한다. Android는 Android와 같은 임베디드 시스템에 최적화된 유명한 SQLite 데이터베이스를 제공한다. 이 데이터베이스는 Android의 많은 핵심 애플리케이션에서 사용한다. 예를 들어, 사용자 주소록이 SQLite 데이터베이스에 저장된다. Android의 Java 구현을 고려하면 JDBC를 사용하여 이러한 데이터베이스에 액세스할 수 있을 것으로 예상할 수 있다. 놀랍게도 Android에는 JDBC API의 대부분을 구성하는 java.sql
및 javax.sql
패키지도 포함되어 있다. 하지만 로컬 Android 데이터베이스에 대해 작업하는 경우에는 이것이 아무 쓸모가 없는 것으로 판명되었다. 대신 android.database
및 android.database.sqlite
패키지를 사용한다. Listing 5에서는 이러한 클래스를 사용하여 데이터를 저장하고 검색하는 예제를 보여 준다.
Listing 5. Android를 사용한 데이터베이스 액세스
public class StocksDb { private static final String DB_NAME = "stocks.db"; private static final int DB_VERSION = 1; private static final String TABLE_NAME = "stock"; private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (id INTEGER PRIMARY KEY, symbol TEXT, max_price DECIMAL(8,2), " + "min_price DECIMAL(8,2), price_paid DECIMAL(8,2), " + "quantity INTEGER)"; private static final String INSERT_SQL = "INSERT INTO " + TABLE_NAME + " (symbol, max_price, min_price, price_paid, quantity) " + "VALUES (?,?,?,?,?)"; private static final String READ_SQL = "SELECT id, symbol, max_price, " + "min_price, price_paid, quantity FROM " + TABLE_NAME; private final Context context; private final SQLiteOpenHelper helper; private final SQLiteStatement stmt; private final SQLiteDatabase db; public StocksDb(Context context){ this.context = context; helper = new SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION){ @Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE_TABLE); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { throw new UnsupportedOperationException(); } }; db = helper.getWritableDatabase(); stmt = db.compileStatement(INSERT_SQL); } public Stock addStock(Stock stock){ stmt.bindString(1, stock.getSymbol()); stmt.bindDouble(2, stock.getMaxPrice()); stmt.bindDouble(3, stock.getMinPrice()); stmt.bindDouble(4, stock.getPricePaid()); stmt.bindLong(5, stock.getQuantity()); int id = (int) stmt.executeInsert(); return new Stock (stock, id); } public ArrayList<Stock> getStocks() { Cursor results = db.rawQuery(READ_SQL, null); ArrayList<Stock> stocks = new ArrayList<Stock>(results.getCount()); if (results.moveToFirst()){ int idCol = results.getColumnIndex("id"); int symbolCol = results.getColumnIndex("symbol"); int maxCol = results.getColumnIndex("max_price"); int minCol = results.getColumnIndex("min_price"); int priceCol = results.getColumnIndex("price_paid"); int quanitytCol = results.getColumnIndex("quantity"); do { Stock stock = new Stock(results.getString(symbolCol), results.getDouble(priceCol), results.getInt(quanitytCol), results.getInt(idCol)); stock.setMaxPrice(results.getDouble(maxCol)); stock.setMinPrice(results.getDouble(minCol)); stocks.add(stock); } while (results.moveToNext()); } if (!results.isClosed()){ results.close(); } return stocks; } public void close(){ helper.close(); } } |
Listing 5에 있는 클래스는 주식 정보를 저장하는 데 사용된 SQLite 데이터베이스를 완전히 캡슐화한다. 애플리케이션에서 사용될 뿐만 아니라 애플리케이션에서 작성하기도 하는 임베디드 데이터베이스에 대해 작업하기 때문에 데이터베이스를 작성하는 데 필요한 코드를 제공해야 한다. Android는 이를 위해 SQLiteOpenHelper
라는 유용한 추상 헬퍼 클래스를 제공한다. 이를 구현하려면 이 추상 클래스를 확장한 후 코드를 제공하여 onCreate
메소드에서 데이터베이스를 작성한다. 이 헬퍼의 인스턴스가 확보되면 임의의 SQL문을 실행하는 데 사용할 수 있는 SQLiteDatabase
의 인스턴스를 얻을 수 있다.
데이터베이스 클래스에는 몇 가지 편의 메소드가 포함되어 있다. 첫 번째는 새 주식을 데이터베이스에 저장하는 데 사용되는 addStock
이다. SQLiteStatement
인스턴스를 사용한다는 것에 주목한다. 이 인스턴스는 java.sql.PreparedStatement
와 비슷하다. addStock
이 호출될 때마다 재사용할 수 있도록 클래스의 생성자에서 이 인스턴스가 컴파일되는 방식에 유의한다. 각각의 addStock
호출 시 SQLiteStatement
의 변수(INSERT_SQL
문자열에 있는 물음표)는 addStock에 전달된 데이터에 바인드된다. 이것 역시 JDBC에서 익숙한 PreparedStatement
와 매우 비슷하다.
다른 편의 메소드는 getStocks
이다. 이름이 나타내듯이 이 메소드는 데이터베이스에서 모든 주식을 검색한다. JDBC에서와 마찬가지로 여기서도 SQL 문자열을 사용한다는 것에 다시 한번 주목한다. SQLiteDatabase
클래스에서 rawQuery
메소드를 사용하여 이를 수행한다. 이 클래스에는 SQL을 직접 사용하지 않고 데이터베이스를 쿼리할 수 있는 몇 가지 쿼리 메소드도 포함되어 있다. 이러한 다양한 메소드는 모두 java.sql.ResultSet
와 매우 비슷한 Cursor
오브젝트를 리턴한다. 쿼리에서 리턴되는 데이터 행 위로 Cursor
를 이동할 수 있다. 각각의 행에서 getInt
, getString
및 기타 메소드를 사용하여 쿼리하는 데이터베이스 테이블의 다양한 열과 연관된 값을 검색할 수 있다. 역시 이것도 ResultSet
와 매우 비슷하다. ResultSet
와 비슷하므로 작업을 완료한 경우에는 Cursor
를 닫는 것이 중요하다. Cursors
를 닫지 않으면 빠르게 메모리 부족이 발생하여 애플리케이션에 오류가 발생할 수 있다.
로컬 데이터베이스를 쿼리하면 프로세스의 속도가 저하될 수 있으며 데이터 행의 수가 많거나 여러 테이블을 결합하는 복합 쿼리를 실행해야 하는 경우에는 특히 더 그렇다. 데이터베이스 쿼리 또는 삽입에 5초 이상 소요되어 Application Not Responding
대화 상자가 표시될 가능성은 없지만 코드가 데이터를 읽고 쓰는 중에 UI를 잠재적으로 멈출 수 있기 때문에 권장되지 않는다. 따라서 이러한 상황을 예방하는 가장 쉬운 방법은 AsyncTask
를 사용하는 것이다. Listing 6에서는 이러한 예제를 보여 준다.
Listing 6. 별도의 스레드에 있는 데이터베이스에 삽입하기
Button button = (Button) findViewById(R.id.btn); button.setOnClickListener(new OnClickListener(){ public void onClick(View v) { String symbol = symbolIn.getText().toString(); symbolIn.setText(""); double max = Double.parseDouble(maxIn.getText().toString()); maxIn.setText(""); double min = Double.parseDouble(minIn.getText().toString()); minIn.setText(""); double pricePaid = Double.parseDouble(priceIn.getText().toString()); priceIn.setText(""); int quantity = Integer.parseInt(quantIn.getText().toString()); quantIn.setText(""); Stock stock = new Stock(symbol, pricePaid, quantity); stock.setMaxPrice(max); stock.setMinPrice(min); new AsyncTask<Stock,Void,Stock>(){ @Override protected Stock doInBackground(Stock... newStocks) { // There can be only one! return db.addStock(newStocks[0]); } @Override protected void onPostExecute(Stock s){ addStockAndRefresh(s); } }.execute(stock); } }); |
단추의 이벤트 리스너를 작성하는 것으로 시작한다. 사용자가 단추를 클릭하면 다양한 위젯(정확하게는 EditText 위젯)에서 주식 데이터를 읽고 새 Stock
오브젝트를 채운다. AsyncTask
를 작성하고 doInBackground
메소드를 통해 Listing 5의 addStock
메소드를 호출한다. 따라서 기본 UI 스레드가 아니라 백그라운드 스레드에서 addStock
이 실행된다. 완료되면 새 Stock
오브젝트를 데이터베이스에서 기본 UI 스레드에서 실행되는 addStockAndRefresh
메소드로 전달한다.
이 기사에서는 Android가 Java 환경에 있는 많은 API의 서브세트만 지원하면서도 기능 면에서 전혀 부족하지 않다는 것을 보여줬다. 네트워킹과 같은 일부 경우에는 익숙한 API를 완전히 구현하지만 더 편리한 방법도 제공한다. 동시성의 경우에서 Android는 따라야 하는 API 및 규칙을 추가한다. 마지막으로 데이터베이스 액세스의 경우 Android는 데이터베이스에 액세스하는 완전히 다른 방법을 제공한다(익숙한 개념이 다수 포함되어 있음). 이들은 표준 Java 기술과 Android의 Java 기술 사이의 임의의 차이점에 그치지 않고 Android 개발의 기본적인 빌딩 블록을 형성한다.