초보 안드로이드 개발자가 꼭 알아야할 필수 라이브러리들

안드로이드 앱을 개발할 때 이미 있는 기능들을 굳이 처음부터 다시 만들 이유는 없습니다. 특히 초심자일때는 더욱 그렇죠. 이번 360AnDev 강연에서, Chris는 안드로이드 앱 개발에 사용할 수 있는 라이브러리들을 소개합니다. 이를 통해 다른 개발자들이 이미 해결해 둔 문제를 쉽게 해결할 수 있죠. 웹 API에서 데이터를 불러오는 일이나, 이미지를 보여주고 캐싱하는 일, 데이터를 저장하고 동기화하는 일을 쉽게 해결할 수 있도록 앱에 적용할 수 있는 라이브러리를 만나보세요.


소개 (0:00)

제 이름은 Chris Guzman이고, Groupon에서 일하는 개발자입니다. 좀 더 소개하자면, 저는 1년 전에 안드로이드 개발자로 전환하기 전까지는 Ruby on Rails 개발자였습니다. 이 강연에서 제가 안드로이드 개발을 시작할 당시에 알고 있었다면 좋았을 여러 라이브러리들을 소개하고자 합니다.

새로운 앱 개발을 시작하셨나요? (0:44)

새로운 앱이나 어떤 기능을 개발하기 시작했는데, 어떤 라이브러리를 사용하는 것이 적절한지 모르겠다고 가정해 봅시다. 가장 좋은 것은 “커뮤니티에서 사용하는 라이브러리”로, 이런 라이브러리들에 대해 말씀드리고자 합니다. 많은 기본 앱들이 비슷한 기능을 갖죠.

  • 여러 뷰들의 관리
  • 이미지 불러오기와 보여주기
  • API에서 데이터 가져오기
  • JSON 파싱 및 해당 JSON으로 뷰 업데이트
  • 저장소에 데이터 저장
  • 그 밖의 것들로 새로운 액티비티 시작

오늘 보여드릴 라이브러리와 버전은 다음과 같습니다.

다만 오늘은 어떻게 build.gradle에 이들을 설정할지는 다루지 않을 예정입니다. 초심자도 충분히 할 수 있을만큼 각 라이브러리의 README에 해당 과정이 잘 나와 있기 때문입니다. 대신 해당 라이브러리의 기능이 무엇인지 와, 왜, 그리고 어떻게 이들을 사용할지 말씀드리겠습니다.

TAaSTY - Tacos As a Service To You (2:04)

45분짜리 해커톤을 시작했다고 생각해 볼까요? 뭔가를 당장 만들어야 할텐데요, 무엇을 만들까요? Tacos As a Service To You라는 말을 줄인 TAsSTY라는 앱을 만들도록 하겠습니다. 타코를 좋아하는 분들을 위한 앱입니다. 텍스트 뷰와 이미지 뷰, 두 개의 버튼이 있고 아래쪽엔 정보 버튼이 있습니다. 이제 앱 개발을 시작해 보죠. 처음으로 할 일은 뷰를 설정하는 일입니다.

<LinearLayout ... android:orientation="vertical">
    <ImageView android:id="@+id/taco_img" .../>
    <TextView android:id="@+id/description" .../>
    <LinearLayout android:orientation="horizontal" .../>
        <Button android:id="@+id/reject" .../>
        <Button android:id="@+id/info" .../>
        <Button android:id="@+id/save" .../>
    </LinearLayout>
    <EditText android:id="@+id/tag" .../>
</LinearLayout>

우리가 만들 앱은 아래처럼 생겼습니다. ImageView가 있고 그 아래 TextView가 있으며, 그 아래에는 또 세 개의 버튼이 있습니다.

android-libraries-beginner-tacoapp

뷰를 사용해야 하는데, 이 시점에서 첫 번째 라이브러리, Butter Knife를 적용해 보겠습니다.

Butter Knife (3:01)

Butter Knife는 판에 박힌 관용 코드 대신 애너테이션을 쓸 수 있도록 해주는 멋진 라이브러리입니다. 어떤 장점이 있을까요? 아마 findViewById를 몇 만번쯤 써오셨을 겁니다. Butter Knife를 쓰면 이런 작업의 양을 확 줄여줍니다. 특히 런타임 중에 비용이 들지 않는다는 점이 매력적이죠. Butter Knife가 해주는 일은 컴파일 타임에 일어나므로 사용자들이 앱을 사용할 때 앱이 느려질까봐 걱정할 필요가 없습니다. 또한 뷰를 찾거나 리스너를 연결할 때 등 자원을 찾는 것을 효율적으로 할 수 있게 합니다. 코드를 보시죠.

<TextView android:id="@+id/description"
    ...
    />

public class MainActivity extends Activity {
    @BindView(R.id.description) TextView description;

    @Override
    protected void onCreate(Bundle bundle) {
        ...
        ButterKnife.bind(this);
        description.setText("Tofu with Cheese on a tortilla");
    }
}

Butter Knife를 액티비티에 적용하면 이런 모습이 됩니다. description의 ID를 가진 이 TextView를 만들게 되죠. 메인 액티비티에서는 멤버 변수를 설정하고 이름짓기만 하면 되므로, description이라고 이름짓겠습니다. BindView 애너테이션을 이용해서 해당 뷰의 ID를 넘깁니다. 그러면 Butter Knife가 이 멤버 변수를 액티비티 전반에 걸쳐 어디에서나 사용할 수 있게 해주죠.

ButterKnife.bind 메서드가 먼저 호출된 이후라면 언제든지 이 멤버 변수를 사용할 수 있습니다. ButterKnife.bind는 어떤 일을 할까요?

public void bind(MainActivity activity) {
    activity.description = (android.widget.TextView) activity.findViewById(2130968577);
}

Butter Knife는 뷰나 자원을 찾아주는 코드를 만들고 프로퍼티로 저장합니다. 이 예제에서는 액티비티에 프로퍼티로 저장했습니다. 이것이 Butter Knife가 컴파일 타임에 bind 메서드를 가지고 하는 일이죠. description을 액티비티의 프로터피로 설정하고 우리 대신 findViewById를 불러줍니다. 그래서 더이상 findViewById를 하느라 시간을 낭비하지 않아도 돼죠.

프래그먼트를 사용할 때를 살펴볼까요?

public class TacoFragment extends Fragment {
    @BindView(R.id.tag) EditText tag;
    private Unbinder unbinder;

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup group, Bundle bundle) {
        ...
        //Important!
        unbinder = ButterKnife.bind(this, parentView);
        tag.setHint("Add tag. Eg: Tasty!, Want to try")
        return view;
    }

    @Override
    public void onDestroyView() {
        super.onDestroyView();
        //sets the views to null
        unbinder.unbind();
    }
}

프래그먼트를 사용한다면 조금 다른 방식을 사용해야 하지만 여전히 단순합니다. 두 개의 인자를 넘기고 unbinder에 레퍼런스를 설정한 것을 볼 수 있는데요. 그 이유는 프래그먼트가 액티비티와는 다른 생명주기를 갖기 때문입니다. 다시 말해서 만약 프래그먼트가 계속 따라다닌다면, Butter Knife로 설정해둔 레퍼런스와 뷰에 프래그먼트가 종속되므로 가비지 컬렉션이나 메모리 에러가 발생할 수 있기 때문입니다. 반면 예제의 방식대로 만들고 onDestroyView()나 프래그먼트를 닫는 곳에서 unbinder.unbind() 호출한다면 이들 뷰의 레퍼런스들을 null로 만들어 줍니다. 이후 가비지 컬렉션이 발생할 때 릴리즈되겠죠.

또한 이벤트 리스너 기능도 Butter Knife의 큰 장점입니다.

@OnClick(R.id.save)
public void saveTaco(Button button) {
    button.setText("Saved!");
}

@OnClick(R.id.reject)
public void reject() {
    Log.d("RejectBtn", "onClick")
}

OnClick 리스너를 붙일 때를 생각해 볼까요? 메서드를 작성하고 OnClick이라는 이션을 붙인 뒤, 리스너를 붙이고 싶은 뷰의 ID를 넘기면 됩니다. 이 예제에서는 OnClick 리스너를 저장 버튼에 붙였습니다. 제가 한 다른 일은 메서드 안에서 해당 버튼의 레퍼런스를 뷰에 전달한 것입니다. 이로써 이벤트 리스너가 실행될 때 동적으로 해당 버튼을 업데이트할 수 있습니다. 여기서는 텍스트를 설정했는데, 알파 값을 설정하거나 숨기거나 하는 여러가지 다른 일도 할 수 있습니다.

Butter Knife를 사용하면 뷰에 레퍼런스를 필수적으로 넘기지 않아도 되므로 상당히 유연한 코드를 작성할 수 있습니다. 취소 버튼을 누르면 해당 버튼의 뷰를 더 업데이트할 이유가 없어질테죠. 사용자가 버튼을 클릭했음을 단지 로그로 남기거나, 5만 개의 분석 라이브러리가 있다 치면 이들에게 취소 버튼이 눌렸다고 알리면 됩니다. Butter Knife는 이런 리스너를 지원해줍니다.

자원 적용 (7:07)

Butter Knife을 사용해서 자원을 넣을 수도 있습니다.

class MainActivity extends Activity {
    @BindString(R.string.title) String title;
    @BindDrawable(R.drawable.star) Drawable star;
    // int or ColorStateList
    @BindColor(R.color.guac_green) int guacGreen;
    // int (in pixels) or float (for exact value)
    @BindDimen(R.dimen.spacer) Float spacer;
}

스트링 파일이 있고 거기서 스트링을 가져오는 상황을 가정해 보겠습니다. 해당 파일에서 스트링을 찾고 ID를 넘기고 액티비티에 프로퍼티로 설정하기만 하면 됩니다. drawable, color, dimension에도 마찬가지의 일을 할 수 있죠. color나 dimension에서 int 등을 가져올 수 있습니다. 더 멋진 기능은 뷰의 그룹화입니다.

@OnClick({ R.id.save, R.id.reject})
public void actOnTaco(View view) {
    if (view.getId() == R.reject) {
        Toast.makeText(this, "Ew Gross!", LENGTH_SHORT).show();
    }
    else {
        Toast.makeText(this, "Yummy :)", LENGTH_SHORT).show();
    }
    //TODO: implement
    getNextTaco();
}

이 경우 savereject 버튼 중 하나를 누르면 같은 동작이 일어나도록 합니다. OnClick 애너테이션을 붙이고 뷰의 리스트에 넘깁니다. 그 후 해당 메서드에서 뷰에 레퍼런스를 넘기면, 이들 뷰 ID나 뷰에 접근할 수 있는 다른 프로퍼티에서 원하는 동작을 할 수 있습니다.

취소를 누르면 “Ew Gross!”라는 메시지를 보내는 Toast를 만들었습니다. 승인을 누르면 “Yummy”라는 메시지가 나오죠. 이 getNextTaco 메서드가 무엇을 하던 이들 뷰가 OnClick 리스너에 묶이도록 합니다. getNextTaco 메서드에 대해서는 이후에 좀 더 말씀드리겠습니다.

뷰를 리스트로 묶는 것을 말하는 김에 다른 것도 말씀드리겠습니다. 모든 뷰에 프로퍼티를 한 번에 적용할 수도 있답니다.

@BindViews({R.id.save, R.id.reject})
List<Button> actionButtons;

ButterKnife.apply(actionButtons, View.ALPHA, 0.0f);

savereject 버튼의 리스트를 만들고 ButterKnife.apply 메서드를 통해 코드에서 버튼들의 알파 값을 바꿀 수 있도록 했습니다. 또한 Butter Knife로 동작을 세밀하게 조정할 수도 있습니다.

ButterKnife.apply(actionButtons, DISABLE);
ButterKnife.apply(actionButtons, ENABLED, false);

static final ButterKnife.Action<View> DISABLE = new ButterKnife.Action<View>() {
    @Override public void apply(View view, int index) {
        view.setEnabled(false);
    }
};
static final ButterKnife.Setter<View, Boolean> ENABLED = new ButterKnife.Setter<View, Boolean>() {
    @Override public void set(View view, Boolean value, int index) {
        view.setEnabled(value);
    }
};

ButterKnife.apply를 호출해서 버튼 리스트를 넘길 수 있고, apply 메서드를 사용할 때 발생하도록 하고 싶은 일도 넘길 수 있습니다. 첫 번째 예제에서는 모든 버튼들의 enabled를 false로 해서 비활성화했죠. 두 번째 예제에서는 보다 미세한 조정을 했는데요, 세 번째 인자를 넘겨서 뷰의 프로퍼티를 변경했습니다. 여기서는 setEnabled를 했는데, ID를 기반으로 컬러값을 넘겨서 변경할 수도 있습니다.

이제 getNextTaco 메서드를 만들겠습니다.

private void getNextTaco() {
    ButterKnife.apply(actionButtons, DISABLE);
    //TODO: implement
    setTacoImage();
}

버튼을 비활성화해서 사용자들이 두 번 클릭하지 못하도록 합니다. 이제 Taco 이미지를 설정해볼 차례로, Picasso를 사용할 예정입니다.

Picasso (9:47)

Picasso는 이미지를 다운로드하고 이미지뷰에 보여주도록 하는 훌륭한 라이브러리입니다. 특히 HTTP 요청을 자동으로 만들어준다는 큰 장점이 있습니다. 어떤 종류인지 걱정할 필요도 없죠. 또한 Picasso는 이미지 캐싱도 해주므로 요청을 다시 하는 것을 고민할 필요도 없습니다. 크롭이나 중심 맞추기, 크기 조정 등 이미지와 관련한 어떤 종류의 일이라도 쉽게 할 수 있도록 합니다. 메인 스레드에서 다운로드하지 않도록 처리해주므로 메인 스레드에서 네트워킹을 하면서 발생하는 예외도 걱정할 필요가 없죠.

혹시 RecyclerView를 사용한다면, 어떤 이미지라도 잘 넣어주고 스크린 밖에서 뷰가 재활용될 때는 부착된 이미지도 잘 없애주는 Picasso를 사용해 보세요.

잠깐 질문을 드려보겠습니다. 두 가지 코드를 보여드릴텐데요, 어떤 쪽이 좋으신가요? 제가 안드로이드 개발로 전환한 시점에서 처음으로 이미지 다운로드 방법을 찾아볼 때, Stack Overflow에서 아래 코드를 찾았었습니다.

private Bitmap DownloadImage(String url)
{
    Bitmap bitmap = null;
    InputStream in = null;

    try
    {
        in = OpenHttpGETConnection(url);
        bitmap = BitmapFactory.decodeStream(in); in.close();
    }
    catch (Exception e)
    {
        Log.d("DownloadImage", e.getLocalizedMessage());
    }

    return bitmap;
}

인풋 스트림을 사용해서 무언가를 다운받고, 잘못될 때를 대비해서 try catch를 사용했습니다. Stack Overflow의 코드를 그대로 붙여넣기했는데, 무슨 일을 했는지는 잘 몰랐죠. 제가 바라던 바가 아니었습니다.

Picasso를 사용하면 다음처럼 됩니다.

Picasso.with(context)
        .load("http://placekitten.com/200/300")
        .into(imageView);

context와 이미지 URL, 이미지를 넣을 imageView를 넘깁니다. URL은 실제 URL이고요. 단지 dimension을 넘기기만 하면 해당 사이즈의 이미즈를 넣어줍니다. Picasso는 이런 멋진 기능들을 정말로 쉽고 편하게 제공합니다.

.placeholder(R.mipmap.loading) //can be a resource or a drawable
.error(R.drawable.sad_taco) //fallback image if error
.fit() //reduce the image size to the dimensions of imageView
.resize(imgWidth, imgHeight) //resizes the image in pixels
.centerCrop() //or .centerInside()
.rotate(90f) //or rotate(degrees, pivotX, pivotY)
.noFade() //don't fade all fancy-like
  • Picasso가 그림을 실제 다운로드 해주므로 기다리는 동안 보여줄 placeholder 이미지를 설정할 수 있습니다. resource나 앱 로컬의 drawable를 placeholder 이미지로 설정하면 됩니다. 만약 이미지가 다운로드되지 않아서 에러가 발생하면 기본 이미지를 보여줄 수 있습니다.
  • 이미지 뷰의 dimension에 맞게 자동으로 이미지를 맞출 수 있습니다. 아주 편리하죠. 혹은 직접 리사이즈할 픽셀을 넣어서 리사이즈할 수도 있습니다.
  • 중앙에 맞게 자르거나 중앙에 맞게 넣거나 센터에 맞춰 회전하거나 중앙이 아닌 지점을 기준으로 회전하는 등의 작업을 할 수 있습니다.
  • Medium.com에서 보던 효과처럼 이미지를 자동으로 흐릿하게 해줍니다. 만약 이 효과가 싫다면 noFade 옵션으로 제거할 수 있습니다.

웹에서 이미지를 불러올 때만 Picasso를 사용할 수 있는건 아닙니다. drawable이나 파일 스트링 혹은 새 파일 등에도 사용할 수 있습니다.

Picasso.with(context).load(R.drawable.salsa).into(imageView1);
Picasso.with(context).load("file:///asset/salsa.png").into(imageView2);
Picasso.with(context).load(new File(...)).into(imageView3);

이제 어떻게 이미지를 불러오는지 살펴봤으니 우리 앱에 적용해 볼까요?

//Butter Knife!
@BindView(R.id.taco_img) ImageView tacoImg;

private void setTacoImage() {
    Picasso.with(context)
        .load("http://tacoimages.com/random.jpg")
        .into(tacoImg);
}

private void getNextTaco() {
    ButterKnife.apply(actionButtons, DISABLE);
    setTacoImage();
    //TODO: implement
    loadTacoDescription();
}

우리 앱에서 ImageView를 찾기 위해 Butter Knife를 사용하고 랜덤한 타코 이미지를 불러와서 설정하겠습니다. 다음 타코의 description을 불러오기만 하면 됩니다. 이 타코 이미지를 가져오기 위해 API 호출을 해야 하는데요. 잠깐 다른 것을 먼저 둘러본 후 우리 모델에 적용해 보겠습니다.

모델은 JSON으로 설정할 예정입니다. JSON 얘기가 나온 김에 Gson에 대해 말씀드릴까 합니다.

Gson (13:29)

Gson은 JSON와 Java 객체를 상호 변환해주는 라이브러리입니다. Gson을 사용하는 것의 장점은 클래스에 애너테이션이 필요하지 않다는 것인데요. 정말 효율적이고 자주 사용되는 라이브러리죠. Gson이 어마어마한 영역을 차지하는 통계를 본 적도 있습니다. 일단 우리 모델을 살펴볼까요?

class Taco {
    private String description;
    private String imageUrl;
    private String tag;
    //not included in JSON serialization or deserialization
    private transient boolean favorite;
    Taco(String description, String imageUrl, String tag, boolean favorite) {
    ....
    }
}

구식의 평범한 Java 객체 모델입니다. description 스트링과 imageUrl 스트링, tag를 가지고 있고, favorite이라는 transient 프로퍼티도 있습니다. Gson을 사용할 때 프로퍼티에 transient를 붙이면 JSON을 시리얼라이즈할 때 해당 필드를 건너 뜁니다. 다음으로 new taco를 만들 수 있는 생성자가 보입니다. 이제 Java 객체를 Gson 객체로 바꾸려면 어떻게 하는지 보여드리겠습니다.

// Serialize to JSON
Taco breakfastTaco = new Taco("Eggs with syrup on pancake", "imgur.com/123", "breakfast", true);
Gson gson = new Gson();
String json = gson.toJson(breakfastTaco);

// ==> json is {description:"Eggs with syrup on pancake", imageUrl:"imgur.com/123", tag:"breakfast"}

// Deserialize to POJO
Taco yummyTaco = gson.fromJson(json, Taco.class);
// ==> yummyTaco is just like breakfastTaco except for the favorite boolean

생성자로 Gson 객체를 만들고 제가 오늘 아침에 먹은 breakfastTaco를 넣었습니다. description과 imageUrl, tag, favorite 등을 넘길 수 있죠. 새 Gson 객체를 가지고 JSON 메서드 두 개를 부른 후 해당 Java 객체를 넘기기만 하면 됩니다. 세 번째 명령어에서 모델 객체를 JSON으로 바꾸면 어떻게 보이는지 알 수 있습니다. favorite이 true라는 점이 빠졌는데, 이는 우리가 favorite을 transient 프로퍼티로 설정했기 때문입니다.

이제 JSON으로부터 Java 오브젝트를 디시리얼라이즈하는 반대 상황을 생각해 보겠습니다. Gson 인스턴스의 메서드, fromJSON을 호출하고 JSON 스트링과 디시리얼라이즈될 클래스를 넘깁니다.

yummyTacobreakfastTaco와 동일한 객체인 것을 아시겠나요? transient 프로퍼티라 제외된 boolean인 favorite를 빼고는 같은 프로퍼티를 가집니다. Gson 의 장점은 현재 클래스나 슈퍼 클래스의 필드가 기본으로 포함된다 는 것입니다. 다차원 배열도 지원하므로 만약 HTTP를 통해 체스를 두려고 할 때도 Gson을 사용할 수 있죠.

Gson에 대해 더 알아둘 것은 Java 객체를 JSON으로 시리얼라이즈하는 경우에 null 필드가 있으면 해당 필드를 건너뛴다는 것입니다. 우리 타코에 tag가 빠졌다고 가정해보면, 그 tag는 null이 되겠죠. 이걸 JSON으로 변환하면 해당 키밸류 쌍은 JSON에 아예 들어가지 않습니다. JSON에서 디시리얼라이즈하는 경우에 JSON이 null인 키밸류 쌍을 갖는다면 프로퍼티와 일치하지 않게되고 역시 null이 됩니다. 즉, 타코 JSON을 다운받는 경우에 description이 없다면 Java 객체의 description은 null이 되는 겁니다. 어쩌면 당연한 결과겠죠?

Gson을 커스터마이즈할 수도 있습니다. 빙산의 일각일 뿐이지만 팁 하나를 보여 드리겠습니다.

//Set properties to null instead of ignoring them
Gson gson = new GsonBuilder().serializeNulls().create();

//Keep whitespace
Gson gson = new GsonBuilder().setPrettyPrinting().create();

첫 번째 포인트는 null을 시리얼라이즈하는 겁니다. JSON을 시리얼라이즈할 때 null을 건너뛴다고 말씀드렸는데요. GsonBuilder 내의 시리얼라이즈된 null을 사용해서 이를 바꿀 수 있습니다. Gson은 기본적으로 공백을 자동 제거하기 때문에 혹시 공백을 유지해야하는 경우라면 setPrettyPrinting을 사용하세요.

public class Taco {

    @SerializedName("serialized_labels")
    private String tag;

}

가끔 엉망인 API를 사용해야 하는 경우가 있습니다. API에서 snake case(단어 중간에 언더라인을 붙이는 방식: snake_case)를 사용했다던지 철자가 잘못됐다던지, 기본 이름을 사용하기 싫을 경우가 있죠. 이 경우 자신의 프로퍼티를 작성한 후 애너테이션으로 태그하고 API의 이름을 넘겨버릴 수 있습니다. 이후부터는 JSON에서 받아와서 시리얼라이즈된 라벨을 사용하는 대신 태그된 프로퍼티를 호출해서 Java 객체를 사용할 수 있습니다.

가끔은 date 포맷을 커스터마이징해야할 때도 있는데요, Gson에서는 어떻게 하는지 보여드리겠습니다.

public String DATE_FORMAT = "yyyy-MM-dd";

GsonBuilder gsonBuilder = new GsonBuilder();
gsonBuilder.setDateFormat(DATE_FORMAT);
Gson gson = gsonBuilder.create();

date 포맷을 만들고 Gson을 만들어서 해당 포맷을 넘겨 줍니다.

이제 타코의 description을 불러올 준비가 끝났습니다.

private void getNextTaco() {
    ButterKnife.apply(actionButtons, DISABLE);
    setTacoImage();
    //TODO: implement
    loadTacoDescription();
}

타코의 description을 받아오기 위해 웹에 호출을 보낼텐데요, 여기서 AsyncTask 대신 Retrofit을 사용하겠습니다.

Retrofit (18:27)

네트워킹에 모두 AsyncTask를 사용하는 분이라면 다음과 같은 장점이 있으므로 Retrofit 도입을 권합니다.

  • 안전한 형변환
  • 인증 기능 내부 지원
  • Gson을 사용할 경우 POJO로 JSON 파싱

바로 앞 챕터에서 Gson을 말씀드렸으니 이제 Gson 팩토리에 데이터를 넘길 겁니다. 참, Retrofit은 RxJava도 지원합니다. Retrofit은 동기적으로나 비동기적으로 HTTP 요청을 실행할 수 있게 해줍니다. 어떻게 사용하는지 볼까요?

public interface TacoApi {
    // Request method and URL specified in the annotation
    // Callback for the parsed response is the last parameter

    @GET("random/")
    Call<Taco> randomTaco(@Query("full-taco") boolean full);

    @GET("contributions/")
    Call<List<Contributor>> getContributors();

    @GET("contributions/{name}")
    Call<Contributor> getContributors(@Path("name") String username));

    @POST("recipe/new")
    Call<Recipe> createRecipe(@Body Recipe recipe);
}

먼저 인터페이스를 만들어야 합니다. 저는 TacoApi라고 만들었는데 4개의 API를 포함합니다. randomTaco와 생성자들, contributor 이름과 새 레시피죠. 지금부터 하나하나 살펴보겠습니다.

랜덤으로 타코를 얻어오고 싶다면 randomTaco 메서드를 부르면 됩니다. boolean인 fulltruefalse 값을 갖죠. full-taco라는 쿼리로 애너테이션했습니다.

이런 식으로 Retrofit을 사용해서 인자를 애너테이션하면 서버에 해당 쿼리 매개변수를 따라 요청을 보냅니다. 우리의 경우 randomTaco true를 호출하면 full-tacotrue라고 변환되죠. 그러면 전체 description이 넘어오게 됩니다. 만약 full-taco false라면 모든 재료를 받게 됩니다. 직접 손으로 만들어야 하죠.

두 번째 API에는 인자가 없고 contributor 리스트를 넘겨받게 됩니다. 리스트를 받았으니 리스트 안의 내용을 하나하나 순회해야 할테죠.

세 번째 API인 getContributors 메서드에서는 내부의 인자에서 패스의 URL을 넘깁니다. getContributors의 역할은 contributor의 이름을 넘기는 것이고, 이 경우 contributor가 만든 모든 레시피를 받게 됩니다. 패스에 이름을 넘겨줘야 하므로 path로 애너테이션하고 스트링인 username을 보내며, 해당 URL을 중괄호로 표시합니다. 이 중괄호 안이 우리가 넘긴 인자로 변환되죠.

마지막으로 중요한 네 번째 API에서는 @POST 애너테이션이 보입니다. 인자에 @body 애너테이션을 붙이면, 여기서 해당 Java 객체를 JSON 객체로 시리얼라이즈해서 서버로 보냅니다. 이처럼 Retrofit은 HTTP에서 사용하는 명령어를 모두 사용할 수 있습니다.

JSON을 동기적으로 받는 코드는 아래와 같습니다.

Retrofit retrofit = new Retrofit.Builder()
    .baseUrl("http://taco-randomizer.herokuapp.com/")
    .addConverterFactory(GsonConverterFactory.create())
    .build();

// Create an instance of our TacoApi interface.
TacoApi tacoApi = retrofit.create(TacoApi.class);

// Create a call instance for a random taco
Call<Taco> call = tacoApi.randomTaco(true);

// Fetch a random taco
// Do this off the main thread
Taco taco = call.execute().body();

실제 API인 http://taco-randomizer.herokuapp.com/ 을 만들었습니다. 새 Retrofit 인스턴스를 만들고 기본 URL을 넘긴 후 이를 JSON와 POJO로 상호변환해주는 JSON converter factory를 더하고 build 했습니다. 다음 줄에서는 타코 API의 인스턴스를 받아온 후 Call 객체의 인스턴스를 사용할 수 있죠.

Call 객체는 OkHTTP와 Retrofit에서 옵니다. 동기적이나 비동기적 호출을 만들 수 있고, 동작 중이나 시작 전에 호출을 취소할 수도 있습니다. 같은 요청을 다시 보내도록 호출을 복제할 수도 있습니다. JSON을 동기적으로 받길 원한다면 호출을 실행해서 body에 타코 오브젝트를 받아올 수 있는데요, 메인 스레드에서 하지 않거나, 비동기적으로 하시길 추천합니다.

이번엔 새로운 레시피를 포스팅하는 것을 알아보도록 하겠습니다.

Recipe recipe = new Recipe();
Call<Recipe> call = tacoApi.createRecipe(recipe);
call.enqueue(new Callback<Recipe>() {
    @Override
    public void onResponse(Call<Recipe> call, Response<Recipe> response) {

    }

    @Override
    public void onFailure(Call<Recipe> call, Throwable t) {

    }

Recipe를 만들고 createRecipe() 메서드에 넘긴 후 call.enqueue()를 사용했습니다. enqueue()가 하는 일은 비동기적으로 요청을 만들고 응답과 실패, 두 가지 콜백을 주는 일입니다. 이런 조건을 가지고 원하는 것은 무엇이든 할 수 있습니다.

정말 좋은 팁을 하나 드리겠습니다. 기본 URL을 변경할 수 있다는 겁니다.

새로운 버전을 작업하는 경우 전체 URL을 넘기고 해당 메서드를 애너테이트할 수 있습니다.

//Change the base url
@POST("http://taco-randomizer.herokuapp.com/v2/taco")
private Call<Taco> getFromNewAPI();

//Add headers
@Headers({"User-Agent: tacobot"})
@GET("contributions/")
private Call<List<Contributor>> getContributors();

이 방식으로 기본 URL을 건너뛸 수 있고, 필요하면 헤더도 추가할 수 있습니다. 랜덤 타코를 얻으려면 아래 같은 코드를 사용합니다.

private void getNextTaco() {
    ...
    loadTacoDescription();
}

private void loadTacoDescription() {
    Call<Taco> call = tacoApi.randomTaco(false);
    call.enqueue(new Callback<Taco>() {
        @Override
        public void onResponse(Call<Taco> call, Response<Taco> response) {
        //Set description from response
        Taco taco = response.body;
        //TODO: implement
        saveTaco(taco);
    }
    
    @Override
    public void onFailure(Call<Taco> call, Throwable t) {
        //Show error
    }
}

랜덤 타코를 요청하는데 이 요청을 비동기식으로 만들 겁니다. 성공한다면 그 타코를 저장할 것이고, 실패한다면 45분짜리 해커톤이니만큼 무시해 버리겠습니다.

Realm (24:20)

saveTaco 메서드의 역할에 대해 알아보겠습니다. 나중을 위해 타코 레시피를 저장하려면 Realm을 사용하는 것이 좋습니다. Realm은 SQLite를 대체하는 훌륭한 라이브러리죠.

Realm의 장점은 다음과 같습니다.

  • 정말 정말 쉽고 설정도 간단합니다.
  • 우리가 사용하는 모델을 데이터베이스에 확장해서 사용할 수 있습니다.
  • 처음부터 모바일을 위해 만들어졌습니다.
  • 많은 쿼리들이 동기적으로 돌려도 충분히 빠릅니다. 비동기적으로 사용하기를 권하지만 필요하다면 동기적으로 해도 무방합니다.
  • 하나의 앱에 여러 Realm 데이터베이스를 사용할 수 있습니다. 이 강연의 주제와는 좀 거리가 있으니 인터넷을 참고하세요.

Realm을 설정하기 위해서는 두 단계가 필요합니다. 먼저 우리 객체가 RealmObject을 상속하게 합니다.

public class Taco extends RealmObject {
    private String description;
    private String tag;
    private String imageUrl;
    private boolean favorite;
    //getters and setters
}

계속해서 사용해오던 같은 타코 객체를 사용했습니다. 그 다음 Realm을 설정합니다. RealmConfiguration 인스턴스를 받아오고 Realm의 기본 인스턴스를 얻습니다.

Set-up Realm
// Create a RealmConfiguration
// saves the Realm file in the app's "files" directory.
RealmConfiguration realmConfig =
    new RealmConfiguration.Builder(context).build();
Realm.setDefaultConfiguration(realmConfig);

// Get a Realm instance for this thread
Realm realm = Realm.getDefaultInstance();

새 스레드에서 Realm을 사용할 때마다 새 Realm 인스턴스를 가져와야 합니다. 데이터베이스에 객체를 저장하는 방법은 다음과 같습니다.

// Persist your data in a transaction
realm.beginTransaction();

// Persist unmanaged objects
final Taco managedTaco = realm.copyToRealm(unmanagedTaco);

// Create managed objects directly
Taco taco = realm.createObject(Taco.class);

realm.commitTransaction();

모든 데이터가 트랜잭션 내에서 처리되도록 해야 합니다. 가장 먼저 할 일은 트랜잭션을 시작하는 일입니다. Realm이 자동으로 모든 객체를 메모리에서 관리해주므로, Realm에 이를 복사하거나 Realm 데이터베이스에서 객체를 만들 수 있습니다.

이미 타코 객체가 있다고 생각해 보죠. URL에 description을 설정하긴 했지만 데이터베이스에 저장하고 싶다면 realm.copyToRealm을 호출하고 만든 객체를 넘깁니다.

혹시 새로운 타코 객체를 만든다면 Realm에서 인스턴스를 만들 수도 있습니다. realm.createObject를 사용해서 해당 객체를 만들고 realm.commitTransaction를 호출하면, 짜잔! 우리 정보가 데이터베이스에 잘 들어가게 됩니다.

데이터 접근은 어떻게 하냐고요? 만약 좋아하는 타코를 모두 가져오고 싶다면 Realm에 다음처럼 RealmResults를 요청할 수 있습니다.

// Get a Realm instance for this thread
Realm realm = Realm.getDefaultInstance();

//find all favorite tacos
final RealmResults<Taco> likedTacos =
    realm.where(Taco.class).equalTo("favorite", true).findAll();

Realm에다 “내가 좋아하는 타코를 줘 볼래?” 하고 묻기만 하면 됩니다. 그 결과는 RealmResults에 담겨오므로 내부를 순회해서 타코 객체를 사용하듯 꺼내 쓸 수 있습니다.

데이터를 쓰는 다른 방법은 아래와 같습니다. 재차 말하지만 새 스레드에서 Realm을 사용하려면 새 Realm 인스턴스를 가져와야 합니다. 그 다음 아래처럼 트랜잭션 블록을 사용합니다.

// Get a Realm instance for this thread
Realm realm = Realm.getDefaultInstance();

//Transaction block
realm.executeTransaction(new Realm.Transaction() {
    @Override
    public void execute(Realm realm) {
        Taco taco = realm.createObject(Taco.class);
        taco.setDescription("Spaghetti Squash on Fresh Corn Tortillas");
        user.setImageUrl("http://tacoimages.com/1.jpg");
    }
});

이제 이 트랜잭션을 실행할 겁니다. Realm에서 새 객체를 만들고 description와 이미지 URL을 설정합니다. 이 블록이 실행되면 데이터베이스 내에 저장돼죠. 비동기식으로 실행하면 어떨까요?

//Async
realm.executeTransactionAsync(new Realm.Transaction() {
        @Override
        public void execute(Realm bgRealm) {
            Taco taco = bgRealm.createObject(Taco.class);
            taco.setDescription("Spaghetti Squash on Fresh Corn Tortillas");
            user.setImageUrl("http://tacoimages.com/1.jpg");
        }
    }, new Realm.Transaction.OnSuccess() {
        @Override
        public void onSuccess() {
            // Transaction was a success.
        }
    }, new Realm.Transaction.OnError() {
        @Override
        public void onError(Throwable error) {
            // Transaction failed and was automatically canceled.
        }
    });

다시 실행 블록을 사용해서 executeTransactionAsync를 호출하고 필요한 세터를 설정합니다. 그러면 Realm이 onSuccessonError, 두 가지 콜백을 돌려줍니다. 이 시점에서 사용자에게 에러 등의 메시지를 보낼 수 있죠.

타코에 대해 잘 알려지지 않은 사실은 정말 많은 재료가 들어간다는 것입니다. 타코 사이의 관계나 타코와 성분 간의 관계를 어떻게 규정할 수 있을까요?

public class Taco extends RealmObject {
    ...
    private List<Ingredient>
    ...
}

public class Ingredient extends RealmObject {
    private String name;
    private URL url;
}

예를 들어 타코가 성분 리스트를 가지며, 이 성분 객체가 URL에서 이름을 갖는다고 생각해 보겠습니다. 타코와 성분 두 객체가 Realm을 상속받아야 하며, Realm이 이들 간의 관계를 관리해줍니다. 어떤 모습인지 확인해 볼까요?

RealmResults<Taco> limeTacos = realm.where(Taco.class)
                                    .equalTo("ingredients.name", "Lime")
                                    .findAll();

이 코드에서 Realm에게 “라임이라는 성분이 들어가는 모든 타코를 줄래?”하고 물어봤습니다. 그 다음 Reaml이 건네준 Realm results를 limeTacos에 저장했습니다. 그러면 우리가 순회하면서 볼 수 있는 타코의 리스트를 가질 수 있죠. Realm은 전형적인 SQL 관계를 모두 지원합니다. 1:1, 1:다, 다:다 관계가 모두 가능합니다.

또한 Realm은 RealmResults에 사용할 수 있는 검색 컨디션이나 predicate을 지원합니다.

가끔 타코를 지워야 할 경우도 생기기 마련이죠.

// All changes to data must happen in a transaction
realm.executeTransaction(new Realm.Transaction() {
    @Override
    public void execute(Realm realm) {
        // remove single match
        limeTacos.deleteFirstFromRealm();
        //or limeTacos.deleteLastFromRealm();

        // remove a single object
        Taco fishTaco = limeTacos.get(1);
        fishTaco.deleteFromRealm();

        // Delete all matches
        limeTacos.deleteAllFromRealm();
    }
});

limeTacos를 가져왔는데 라임이 아니라 라임스톤을 가져왔다고 가정하고 선호 리스트에서 지워봤습니다.

Realm 트랜잭션 블록에서 할 수 있는 것은 이런 리스트를 가져와서 첫 번째나 마지막 것을 지우는 것입니다. 인덱스로 객체를 지정해서 지울 수도 있고, 필요하다면 전체를 다 지워버릴 수도 있습니다.

Realm의 좋은 점은 Realm 객체나 RealmResults에 change listener를 달 수 있다는 점입니다.

limeTacos.addChangeListener(
    new RealmChangeListener<RealmResults<Taco>>() {
        @Override
        public void onChange(RealmResults<Taco> tacosConLimon) {
        //tacosConLimon.size() == limeTacos.size()

        // Query results are updated in real time
        Log.d("LimeTacos", "Now we have" + limeTacos.size() + " tacos");
        }
    }
);

위 예제에서는 limeTacos에 change listener를 달았습니다. 뭔가 추가되거나 리스트 내의 객체가 바뀌면 이 change listener가 불리고 콜백을 받게 되죠. 콜백의 인자를 tacosConLimon이라고 이름지었습니다.

다른 Realm의 장점은 객체를 자동으로 업데이트해준다는 것입니다. limeTacos에 무언가 추가해도 위 코드처럼 같은 RealmResults를 참조하는 두 개의 리스트가 같은 크기임을 확인할 수 있습니다. 이 타코 리스트로 원하는건 뭐든지 할 수 있습니다.

Realm 활용 팁 (30:24)

Realm 사용의 장점과 활용 팁 몇 가지를 공유해 드리겠습니다. 모델에 기본 키 애너테이션으로 ID를 지정했다면, integer인 ID를 가지고 Realm을 복제하거나 업데이트할 수 있습니다. 즉, 매번 새 객체를 만들지 않고도 메모리 상에 있는 같은 객체를 업데이트할 수 있다는 거죠.

Realm은 Gson과 Retrofit과도 잘 어울립니다. Realm의 최신 버전을 사용하면 게터와 세터를 커스터마이즈할 수 있습니다. 이전에는 되지 않던 버전이 있었으므로 팁이라고 할 수 있겠습니다.

Realm을 사용하면서 가비지 컬렉션이나 이상한 메모리 경고, 에러 등을 피하기 위해서는 Realm 객체나 RealmResult에 적용한 change listener를 꼭 지워야 한다는 것입니다. 뭔가 참조가 끝난 경우 꼭 change listener를 지우는 것을 잊지 마세요.

@Override
protected void onDestroy() {

    // Remove the listener.
    realm.removeChangeListener(realmListener);
    //or realm.removeAllChangeListeners();

    // Close the Realm instance.
    realm.close();
    ...
}

또한 액티비티, 프래그먼트, 스레드 등의 생명 주기에 따라 Realm 객체를 닫는 것을 잊지 마세요. 메모리 유출을 줄이는데 많은 도움이 됩니다.

Realm은 정말 유용한 솔루션을 제공하며, 계속 발전하고 있는 회사로 언젠가 이 문제를 고칠 거라 예상하지만 현재로써는 객체에 스트링 리스트나 원시 타입을 저장할 수 없습니다. 예를 들어 제 타코 객체는 하나의 tag 스트링을 갖는데, 만약 이것이 리스트가 된다면 현재로써는 Realm에 저장할 수 없습니다. API에서 데이터를 다운받는다면 Gson 어댑터를 받는 것이 좋습니다. 혹은 RealmString 같은 스트링의 새 객체를 만드는 것도 우회책이죠.

만약 Realm에 API에서 가져온 데이터를 저장한다면, 해당 객체를 메모리로부터 Realm으로 복사하고, Realm 객체에서 이 복사본을 사용한다는 점입니다. 만약 데이터의 크기가 크거나 복잡한 쿼리가 있다면 메인 스레드에서 사용하지 않도록 주의하세요.

자, 마지막으로 가장 중요한 작업인 새 액티비티를 시작해보겠습니다.

//TODO: implement
goToTacoDetailActivity();

참가하고 있는 해커톤에서 한 30분쯤 지났는데, 아직도 액티비티가 하나 뿐이네요. 다음 액티비티를 만들어 봅시다.

Dart & Henson / Conclusion (33:04)

이제부터는 Dart & Henson을 사용하겠습니다. Dart는 Butter Knife에서 영감을 받아 만들어진 라이브러리의 이름으로, 저희 Groupon에서 많이 사용하고 있습니다.

인텐트 엑스트라를 객체에 프로퍼티로 주입할 수 있게 해주므로 새 액티비티를 시작하고 이들 엑스트라를 인텐트에 실어서 보내는 작업을 한다면 Dart로 쉽게 해결할 수 있습니다.

//MainActivity
intent.putExtra(EXTRA_TACO_DESCRIPTION, "Seasoned Lentils with Green Chile on Naan");
//TacoDetailActivity
tacoDescription = getIntent().getExtras().getString(EXTRA_TACO_DESCRIPTION);

먼저 말씀드린 findViewById처럼, intent.putExtra, key, value, getIntent, getExtras, getString 같은 것을 키에 넣어 보내는 것도 상당히 소모적인 작업입니다. 종종 담았던 곳으로 다시 돌아가서 어떤 key에 담았는지, 변수인지 상수인지 살펴봐야만 하죠. Dart & Henson을 사용하면 이런 귀찮음이 사라집니다. Henson은 읽기 쉽고 도메인에 특화된 언어로 인텐트에 엑스트라를 담을 수 있게 합니다.

둘을 구분해서 말씀드릴텐데요, 먼저 Dart부터 알려드리겠습니다. 인텐트로부터 엑스트라를 가져오는 코드는 다음과 같습니다.

public class TacoDetailActivity extends Activity {
    //Required. Exception thrown if missing
    @InjectExtra boolean favorite;
    @InjectExtra String description
    //default value if left null
    @Nullable @InjectExtra String tag = "taco";
    //Ingredient implements Parcelable
    @Nullable @InjectExtra Ingredient withIngredient;

    @Override
    public void onCreate(Bundle bundle) {
        super.onCreate(bundle);
        Dart.inject(this);
        //TODO use member variables
        ...
    }
}

Butter Knife와 비슷해 보이죠? favorite, description, tag, ingredient, 네 가지 엑스트라가 있습니다. 앞의 두 가지부터 살펴 볼까요?

액티비티의 이들 프로퍼티를 InjectExtra라고 애너테이트했는데, 그냥 그대로 둔다면 액티비티가 시작할때 인텐트에서는 null이 됩니다. 당연히 예외가 발생하겠죠. boolean인 favorite과 스트링인 description인 엑스트라와 함께 액티비티가 시작돼야 합니다. 이런 엑스트라를 옵셔널 필드로 만들고 싶다면 Nullable 애너테이션을 붙이면 됩니다.

이제 인텐트에 엑스트라를 실어 보내지 않아도 앱이 죽지 않을 겁니다. 기본 값도 만들 수 있는데요. 만약 인텐트의 엑스트라에 tag를 깜빡 잊고 보내지 않을 때를 대비해 taco라는 값을 기본으로 할 수 있습니다.

또한 Parcelable를 상속하는 모델이라면 전체 모델을 주입할 수도 있습니다. Dart가 이 모델을 언래핑하고 액티비티의 프로퍼티로 보내줍니다. Dart 인젝트를 사용하면 우리가 정의한 대로 멤버 변수를 사용할 수 있습니다. 이런 엑스트라를 인텐트에 어떻게 설정할까요? 이제 Henson이 등장할 차례입니다.

Generate intent builders with Henson
//Start intent for TacoDetailActivity
Intent intent = Henson.with(context)
    .gotoTacoDetailActivity()
    .favorite(true)
    .description("Seasoned Lentils with Green Chile on Naan")
    .ingredient(new Ingredient())
    .build();
// tag is null or defaults to "taco"
startActivity(intent);

Henson은 자동으로 이 인젝트 엑스트라 애너테이션을 가진 모든 액티비티를 찾아서 자동으로 만들어낸 빈 메서드로 보내줍니다.

또한 Henson은 어떤 엑스트라가 InjectExtra 애너테이션을 갖는지 찾아서 우리가 만든 인텐트의 세터로 사용합니다. favorite에 어떤 것이 전달됐는지, 타코의 description이 무엇인지, 새로운 성분이 무엇인지 볼 수 있고 빌드도 할 수 있습니다. tag를 넣지 않은걸 눈치 채셨나요? tag를 nullable인 주입 엑스트라로 설정하면 null이 될 겁니다. 그러나 앞서 기본 값을 설정했으므로 그에 맞게 taco라는 값이 액티비티를 시작할 때 들어가겠죠.

인텐트와 함께 이 액티비티를 시작하면서 해커톤을 마무리하겠습니다. 아무 엑스트라도 주입하지 않은채 Henson을 사용하고 싶으면 어떻게 할까요? 액티비티를 @HensonNavigable라고 애너테이트하기만 하면 됩니다.

여기서 작은 팁을 드리자면, ProGuard를 사용하시는 분은 아래와 같은 법칙을 적용하시라는 겁니다. 구현할 메서드들도 없이 달랑 이거면 될까요?

grep 'TODO: implement'
=> 0 results

네, 그렇습니다. 이로써 우리의 타코 앱이 완성됐습니다!

Q & A (37:32)

Q: Realm 데이터베이스 안에 저장된 객체가 업데이트됐는지 어떻게 알 수 있나요?

Chris: 객체의 ID 애너테이션을 사용하면 모델에 고유한 ID가 생깁니다. SQL에서 하는 것과 비슷하죠. 앞에서 보여드린 조건문을 사용해서 Realm 객체를 ID로 탐색하고 그 특정 Reaml 객체를 업데이트합니다. ID로 탐색한 다음 업데이트를 하고 트랜젝션을 커밋하면 데이터베이스에 잘 업데이트됩니다.

참고자료


Chris Guzman

Chris Guzman

Chris는 그루폰의 개발자로 원래 ruby on rails 개발자였지만 2015년에 안드로이드 개발자로 거듭났습니다. 개발자로써 커리어를 쌓기 시작한지는 2년이 좀 넘었습니다. Chris는 다른 사람들의 성장을 돕는 것을 좋아하며 Baltimore 초급 개발자 모임의 공동 주최자이자 코드 클래스를 지도하기도 합니다.

번역 임은주