0%

최근에도 큰 규모의 리펙토링을 진행했는데, Fragment 생명주기에 대한 지식이 부족하여 많은 이슈를 발생시켰다. 😭 이번 포스팅에서는 해당 이슈의 원인과 해결법에 대해 적어보려 한다.

TL;DR

  • 프로세스가 재 시작 될 때 Activity, Fragment 의 생명 주기 흐름이 조금 다르게 진행된다.
  • Fragment 에서 Activity 의 데이터를 참조할 때는 onActivityCreated 메소드 내에서 진행하자.

프로세스, Activity, Fragment 의 생명주기

안드로이드의 독특한 특징 중 하나는 프로세스 생명 주기와 어플리케이션 생명 주기와 항상 동일하지 않다는 점이다. 앱이 실행되고 있어도 프로세스를 죽일 수 있고 반대로, 앱을 종료해도 프로세스가 바로 종료되는 것은 아니다.

대표적으로 시스템이 프로세스를 죽이는 경우는 다음과 같다.

  • 메모리가 부족할 경우 시스템은 우선순위가 낮은 프로세스를 죽인다.
  • 앱이 일정 기간 이상 백그라운드 상태에 있을 경우 시스템은 해당 앱의 프로세스를 죽인다.
  • 앱이 허용했던 권한을 해제 시 시스템은 해당 앱의 프로세스를 죽인다.

하지만 이렇게 앱과 프로세스의 수명 주기가 일치하지 않을 경우 유저는 영문도 모른채 간헐적으로 처음부터 재시작하는 앱을 경험하게 될 것이다. 이에 onSaveInstance 메소드를 제공하여 데이터를 백업하고 사용하여 사용자에게 영향을 주지 않도록 설계되어 있다.

문제는 이렇게 ‘시스템이 프로세스를 죽이고, 해당 프로세스가 재시작 됐을 경우’ Activity 와 Fragment 의 생명주기 흐름이 일반적인 상황과는 조금 다르게 진행된다.

일반적인 상황에서 생명 주기 흐름

사용자가 처음 앱을 구동하는 상황으로 가정한다.

1
2
3
4
5
6
7
8
9
10
11
// MainActivity.class
@Override
protected void onCreate(Bundle savedInstanceState) {
Log.e("lifeCycle", "Activity onCreate");
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

// initialize ...

addFragment();
}

위와 같이 onCreate 메소드 에서 Fragment 를 초기화 해준다고 했을 때, LifeCycle 은 다음과 같은 순서로 진행된다.

E/lifeCycle: Activity onCreate
E/lifeCycle: Fragment onAttach
E/lifeCycle: Fragment onCreate
E/lifeCycle: Fragment onCreateView
E/lifeCycle: Fragment onActivityCreated

시스템에 의해 kill 된 프로세스가 재 시작 될 때, 생명 주기 흐름

좀 더 자세한 흐름을 보기 위해 로그를 아래와 같이 세분화 하였다. 그 외에 변경사항은 없다.

1
2
3
4
5
6
7
8
9
10
11
// MainActivity.class
@Override
protected void onCreate(Bundle savedInstanceState) {
Log.e("lifeCycle", "Activity onCreate : before super.onCreate");
super.onCreate(savedInstanceState);
Log.e("lifeCycle", "Activity onCreate : after super.onCreate");

setContentView(R.layout.activity_main);

addFragment();
}

시스템에 의해 kill 된 프로세스를 재 시작 했을 때 LifeCycle 은 다음과 같은 순서로 진행된다.

E/lifeCycle: Activity onCreate : before super.onCreate
E/lifeCycle: Fragment onAttach
E/lifeCycle: Fragment onCreate
E/lifeCycle: Activity onCreate : after super.onCreate
E/lifeCycle: Fragment onCreateView
E/lifeCycle: Fragment onActivityCreated

….!!!

일반적인 상황일 때의 흐름과 큰 차이점이 있다.

  1. base Activity 에서 Fragment 를 복구한다.
  2. Fragment 복구가 완료되면 supre.onCreate() 다음 라인으로 흐름이 반환되어 나머지 로직이 진행된다.

몇 번을 반복 해봐도 흐름 순서가 동일한 것으로 보아 비동기로 진행되는 것은 아닌 것으로 추측된다.

발생할 수 있는 문제

위와 같은 차이점은 Fragment 의 onCreate 메소드 내에서 Activity 의 데이터를 참조할 경우 문제가 발생할 수 있다.

Activity 의 여러 initialize 작업이 진행되기 전에 Fragment 의 onCreate 가 불리기 때문에 존재하지 않는 데이터를 참조하여 NPE 가 발생하게 된다. 내가 겪었던 문제도 정확히 이와 같은 케이스였다.

1
2
3
4
5
6
7
8
9
10
11
12
// MainActivity.class
@Override
protected void onCreate(Bundle savedInstanceState) {
Log.e("lifeCycle", "Activity onCreate : before super.onCreate");
// --> Restore Fragment !!!! : called onAttach(), onCreate()
super.onCreate(savedInstanceState);
Log.e("lifeCycle", "Activity onCreate : after super.onCreate");

setContentView(R.layout.activity_main);

addFragment();
}

해결책

  1. super.onCreate(savedInstanceState) 가 호출되기 전에 initialize 를 진행한다.

    → 추천하지 않는다. 오히려 하위 Activity 가 초기화되지 않았을 때 발생하는 사이드 이펙트가 더 많을 수 있다.

  2. onActivityCreated() 메소드 내에서 Activity 레벨의 변수를 참조한다.

    → 구글에서 권장하는 방법이며, 애초에 이 메소드의 존재 이유라고 생각한다.

    Called when the fragment’s activity has been created and this fragment’s view hierarchy instantiated. It can be used to do final initialization once these pieces are in place, such as retrieving views or restoring state.

    사실 해당 내용을 공부하기 전까지는 이 메소드의 필요성을 잘 느끼지 못했다. 어차피 onCreate() 나 onActivityCreated() 나 Activity 의 onCreate() 가 끝나고 불리는 것은 동일하다고 생각했기 때문이다.

    하지만 onCreate() 는 Fragment 의 생성을 알려줄 뿐 Activity 와 직접적인 연관은 없다.

    docs 에서도 이 사실을 친절히 짚어주고 있다.

    Note that this can be called while the fragment’s activity is still in the process of being created. As such, you can not rely on things like the activity’s content view hierarchy being initialized at this point. If you want to do work once the activity itself is created, see onActivityCreated(android.os.Bundle)

    사실상 오늘 내용의 핵심이 모두 담겨있다…

결론 - 공식 docs 가 먼저다.

항상 넘겨짚는 순간에 실수가 발생한다. 스스로 정확하게 설명할 수 없다면 다시 공부하는 수 밖에 없다.

그리고 위 내용을 찾아보면서 파편화된 정보나 내용의 불일치로 인해 적지않은 시간을 소비했는데 공식 docs 에서 너무 깔끔히 설명하고 있어 허탈함을 조금 느꼈다. 역시 공식 docs 먼저! 😹

포스팅 하고 싶은 주제들은 많은데 아직 마무리를 못하여 여러개의 토막글만 남아있다..
그래서 바람같이 흘러갔던 1월에 느꼈던 점들을 짤막하게 정리해본다.

점점 회고 블로그가 되고있다. 😂

목적에 집중

개발 방향에 대해 논의를 하는데 의견차가 잘 좁혀지지 않을 때가 있다. 각자의 주장에 타당함이 있을 때 어떻게 해야할까?

내가 요즘 가장 많이 하는 말은 ‘목적을 다시 한번 생각해보자.’ 라는 말이다.

아직 팀의 특정 기술 성숙도가 낮을 때 위와 같은 상황이 자주 벌어지곤한다. 일반적인 구현은 크게 문제가 되지 않는데, 점점 규모가 커지고 복잡해질수록 예외상황이 생기면서 방향성이 흐려지기 시작한다. 나같은 경우에는 MVVM 을 처음 공부하고 도입할 때 자주 겪었다. 아키텍처가 이미 정해준 틀이 있는데, 예외를 처리하기는 어렵고. 그리고 흑마술과 같은 방법으로 때우고자 하는 유혹이 강렬하게 찾아온다..

그럴때 우리가 이 기술을 왜 선택했는지, 이 기술은 무슨 목적으로 나온 것인지 살펴보면 의외로 쉽게 해결방법이 나오곤 했다. 용어와 형식이 주는 느낌에 메이지 말고 항상 그 목적성을 상기하는 것이 중요하다고 느낀다.

(기술을 넘어 아키택처, 패러다임 등을 대입해도 동일하게 적용될 수 있다.)

개선이냐, 협업이냐

비교적 오래된 프로젝트를 개발하다보면, 거의 프레임워크화 되어 자주 쓰이는 패턴들이 보이곤 한다. 그리고 새로운 코드를 짤 때 고민에 빠진다.

개선의 관점에서는 고치고 싶은 부분들이 군데군데 보인다. 당시에는 최선의 접근방법이었을 것이지만 지금은 너무 신경써야할 리소스가 많이 드는 방식이다. 개선을 건의할까?

협업의 관점에서는 그동안 팀원들이 쌓아온 팀내의 룰과 같다. 이를 바꾼다는 것은 팀원들의 새로운 학습 리소스 + 예외 케이스에 대한 추가 협의 등 새로운 리소스가 소모될 수 있다. 그냥 사용할까?

두 가지 모두 중요한 측면이라고 생각하기에, 항상 긴 내적갈등을 겪는다.

현재 내 방침은 이슈가 되는 부분이 퍼포먼스 혹은 지속적인 관리 이슈를 유발하는 경우에는 1) 타당한 이유와 2) As-Is 와 To-Be 의 확실한 차이점을 내새워 팀원들을 설득하고, 그외에는 패턴을 그대로 사용하기로 했다. 그리고 팀원들이 모두 동의했을 경우 개선 작업은 반드시 내가 주도하여 마무리 할 것. 뒤에서 불평만 하는 사람이 되기 싫다.

간결한 것이 최선의 설명일 수 있다

최근에 이메일을 작성할 때 받은 피드백이었다.

  • 두괄식 작성
  • 전달하고자 하는 내용은 짧게. 전문 용어나 자세한 히스토리 X.

안드로이드에서만 추가한 내용을 다른 플랫폼에 공유하는 메일이었는데, 나는 개발자의 마인드로 추가된 내용과 히스토리를 엄청 디테일하게 적었다. 그리고 위와 같은 피드백을 받았는데, 그 이유로는

  1. 사람들은 하루에도 수십개의 참조메일을 받기 때문에 길면 잘 안읽을 수 있다(!)
    읽어도 당사자가 아니면 이해하기 어렵다. 결국 내가 전달하고자 하는 내용조차 묻힐 수 있음.
  2. 상세한 히스토리는 추가 요청때 전달해줘도 충분하다.

명쾌했다! 아직도 메일로 전달하는 것은 좀 더 숙달되어야 함을 느낀다.

이직 후 정신없는 하반기를 보냈다. 기대 이상의 성취를 얻은 부분도 있고, 아예 시도조차 못한 부분도 있었다. 스스로 회고하며 기억에 남을 만한 일들을 정리했다.

개발

의도를 드러내는 코드 짜기

우리의 기억력은 빈약하고 왜곡되기 쉽다. 복잡한 비즈니스 로직과 히스토리를 반년 뒤에도 기억할리가 없기 때문에 주섬주섬 주석을 달지만, 이마저도 나중에 보면 이해하기 어려울 때가 많다.

결국은 코드 자체를 통하여 의도를 드러내는 것이 가장 이상적인 형태이다. 이를 실천하기 위해 많은 고민과 노력이 동반 되었고, 특히 하반기에 수강했던 NextStep 과정이 아주 큰 도움이 되었다.

예외 처리와 Stateless 한 코드 짜기

‘설마 이 상황이 발생하겠어?’ 라는 마음가짐으로 코드를 짜면 항상 그 부분에서 문제가 발생하게 되는 것을 피부로 겪었다. 많은 사용자 수와 그에 대응되는 수많은 환경을 고려했을 때, 발생할 수 없는 상황은 없다고 생각하는 것이 옳다. 발생할 수 있는 모든 예외 상황에 대한 가드 처리가 필요하며, 너무 많은 가드 처리는 가독성을 떨어트릴 수 있으므로 언어/코드 레벨에서 사전에 방지할 수 있는 방법은 없는지 많은 고민을 했다. 최근까지 도달한 결론은 꼭 필요한 상태(State)가 아니면 사용하지 않는 방향으로 하여 관리포인트와 사이드 이펙트를 줄여나가자는 것으로 마무리 했다.

아무리 강조해도 지나치지 않은 커뮤니케이션

개발자라고 해서 항상 주어진 업무 시간 전부를 개발에 쓰기는 어렵다. 정기적/비정기적인 커뮤니케이션 구간이 항상 존재하기 때문이다. 피할 수 없는 일이라면 잘하고 싶다. 잘하기 위해 다음과 같은 노력을 했다.

커뮤니케이션 대상에 대한 공통된 합의

같은 개념을 얘기하는 데 다른 언어가 사용되고, 같은 언어를 사용하는데 다른 개념(혹은 구현)으로 설명될 때가 많았다. 이러한 불일치를 보정하는 과정이 발생할 때마다 진행의 병목이 생기고 커뮤니케이션 비용이 높아지는 것이 부담되기 시작했다. 그래서 이를 내가 할 수 있는 일들을 하나씩 진행해봤다.

1) 기본적으로 커뮤니케이션 시작 전에 이야기하고자 하는 목적과 대상을 한번 더 체크 했다. (ex/ 저는 A 의 B 부분에 대해 논의해야하는 것으로 알고 있는데 맞을까요~?) 이렇게 하면 초기 리소스는 조금 더 들 수 있지만 이미 커뮤니케이션이 어느정도 진행된 후 보정하는 것보다는 훨씬 나은 결과를 얻었다. 매번 이 방식을 고수하다보니, 나중에는 먼저 얘기를 꺼내주시는 분들도 생겼다!

2) 팀장님에게 좀 더 정기적인 코드 리뷰 시간 확보를 요청했다. 이 일정은 개념/구현에 대한 논의를 거쳐 통일된 방향으로 나아가는 시간이 되었고, 이 이후 팀원간 커뮤니케이션 속도가 빨라졌을 뿐만아니라 구현에 대한 통일성도 얻어갈 수 있었다.

설득의 기술

기획서에서는 한 줄 분량의 내용인데 수많은 클래스를 고쳐야 한다거나 IOS 에서는 쉽지만 Android 에서는 구현 복잡도가 큰 구현 사항이 넘어올 때가 있다. 모든 요구사항을 다 처리해주는 슈퍼 개발자가 되고 싶지만… 아직 그럴 실력도 안되고 개발 기간도 제한되어 있기 때문에 결국 설득을 위한 커뮤니케이션을 피할 수 없었다.

복잡한 내부 히스토리와 예외 상황이 존재하기는 하나, 기본적으로 내 전략은 다음과 같았다.

  • 개발과 관련된 내용을 배제하고 최대한 이해하기 쉽게 상황과 이유를 설명한다.
  • 구현하지 못하는 내용에 대한 대체 옵션을 제시한다.

전략을 실제 대화로 구성하면 다음과 같다.

“A 스펙은 비슷한 행동을 수행하는 다른 화면에도 영향이 가기 때문에 이번 버전에 일괄적으로 적용하기는 조금 어려울 것 같아요. 다만, 범위를 B 까지만 한다면 가능할 것 같은데 이렇게 진행하는 것은 어떨 까요?”

결국 개발팀에서는 할 수 없는 나름의 이유가 있고, 다른팀에서는 해야하는 나름의 이유가 있기 때문에 무조건 받아줄수도, 무조건 거절할 수 도 없기 때문에 위와 같은 절충안을 생각했다. 그리 어려운 방식은 아니였지만 설득의 성공률은 굉장했다!

개발 외

운동은 장기 레이스로

주짓수에 본격적으로 재미를 붙히기 시작했을 때, 대회에 출전하여 수상해보고 싶은 욕심이 생겼다. 준비 기간이 짧아 몸의 신호를 무시하고 무리하게 운동을 진행했는데 결국 출전 직전에 큰 부상을 겪게 되었다. 손목에 힘을 주기 어려울 만큼 통증이 심해서 주짓수는 물론이고 운동 자체를 몇 달간 쉬게 되었다. 운동을 못하니 체력이 급속도로 떨어지고, 스트레스 해소가 되지않아 멘탈에도 많은 영향을 주었다.

다행히 지금은 많이 회복되어 퇴근 후 가볍게 헬스를 하고 있다. 운동을 다시 시작하니 점차 몸의 활력이 돌아옴을 느낀다. 결국 운동은 죽기 전까지 꾸준히 필요하다. 지치지 않는 장기 레이스를 잘 수행할 수 있도록 적절한 페이스 조절이 필요함을 실감했다.

의도적인 휴식과 충전

학습과 성장에 대한 열망을 넘어 강박 수준까지 가다보니 기습적으로 찾아오는 무기력함과 매너리즘에 속절없이 당했던 순간이 많았다. 이를 방지하기 위해 의도적으로 쉬면서 재충전하는 시간을 만들려고 노력했는데 아, 의도적으로 쉬려고 하는 것도 참 쉽지 않음을 느꼈다. 특히, 하고싶은 것을 하면서 보내는 시간 자체가 계속해서 뒤쳐지고 있다는 느낌이 들어서 이를 떨쳐 내는 것이 쉽지 않았다.

그래도 의식적으로 노력하여 시간을 제법 확보 했고, 나름 알차게 사용했다고 생각된다. 특히 올 하반기에는 경제와 관련된 많은 책을 읽으며 새로운 분야로 발을 내딛는 재미를 느꼈고, 비슷한 관심사를 가진 사람들이 모인 독서 모임을 신청하여 다양한 사람들의 삶을 엿보는 시간도 가지게 되었다. 운동도 이러한 시간의 연장선이라고 볼 수 있다.

무엇을 하든 유연하게 균형을 맞추는 일이 참 중요하다고 생각한다. 너무 뻣뻣하면 부러지기 쉽다.

코드에 대한 고민, Next Step

이직 후, 오랜 기간 서비스된 앱을 개발하게 되면서 가장 자주 들었던 생각이 있다.

아, 이건 레거시 구조 때문에 더이상 건드리기가 어렵다.

사실 이미 존재하는 레거시 코드를 탓하는 것은 큰 의미가 없다. 중요한 것은 앞으로 내가 작성하는 코드 역시 이러한 레거시 코드가 될 수 있다는 것이었다. 그래서 최근부터 유지 보수하기 쉬운 코드란 무엇인지, 레거시는 왜 관리하기가 어려운지 고민하기 시작했고, 관련된 내용의 글과 책을 찾아보며 나름의 결론을 내고자 했었다.

그러던 중 우연히 한 개발자의 글을 통해, 내가 고민하고 있는 포인트와 비슷한 내용을 다룬 강의가 있다는 것을 알게 되었고 그것이 바로 Next Step 의 강의 였다. 소개된 커리큘럼과 진행 방식을 보며, 투자할 가치가 있다고 생각되었고 바로 수강 신청을 했다.

의도적인 수련을 통한 학습

최근 읽었던 책에서 의도적 수련 이라는 개념을 이야기하는 부분이 있었다. 내용의 핵심은, 매일 어떠한 일을 반복하더라도 이를 의도적으로 분석하고 개선하고자 하는 노력이 없으면 실력이 늘지 않는다는 것이다. 우리가 양치질을 매일 반복한다고 양치질의 전문가가 되지 않는 것처럼 말이다.

그리고 Next Step 의 독특한 수업 진행 방식은 이러한 의도적 수련을 지속적으로 할 수 있도록 도와주는 형태로 이루어져 있었다.

수업은 다음과 같은 루틴으로 반복된다.

  1. 주 1회 TDD, Clean Code, Refactoring 에 대한 오프라인 강의가 이루어 진다.
  2. 배운 개념을 바탕으로 해당 주차의 과제를 수행한다.
  3. 과제는 단계별로 이루어져 있으며 각 단계의 요구사항을 모두 구현하면 PR 을 보내, 담당 리뷰어에게 피드백을 받는다.
  4. 모든 단계의 피드백을 반영하면 해당 과제가 마무리 된다.
  5. 1-4 의 과정 반복

일반적인 강의와 가장 큰 차이점은 구현한 코드에 대한 리뷰를 받는 과정이 있다는 것이다.

매번 각 과제의 단계마다 구현한 내용에 대하여 피드백을 받는다. 이때 피드백은 문제의 해답이 아니라 방향성만 제시 해주기 때문에, 스스로 생각해보고 각각의 결정에 대한 트레이드 오프를 고려하는 시간을 만들어준다. 또한 다소 추상적일 수 있는 수업 내용을 실제 코드에서 적용해 나가는 과정에서 제대로 개념을 이해하고 넘어갈 수 있었다.

(피드백만 잘 반영해 나가도, 어느샌가 자연스럽게 클린 코드를 바탕으로 리펙토링하고 있는 자신을 발견할 수 있다.)

기억에 남는 수업 내용

수업 내용 중 기억에 많이 남았던 부분을 정리하고 이에 대한 생각들의 정리이다. (수업 내용의 일부는 미리 허락을 받고 적었다.)

클린코드를 지향해야 하는 이유

우리가 클린코드를 지향 해야하는 이유는 무엇일까?

이 물음은 인간은 완벽하지 않다는 개념에서 출발한다. 코드를 짠 순간은 빈틈이 없어 보이지만, 시간이 지날수록 스스로도 과거의 자신을 이해하지 못하며 관리하기 어려운 코드가 되어 버린다. 왜냐하면 인간 역시 스스로 계속 변화하는 변수이기 때문이다. 따라서 코드를 짜는 개발자를 믿을 수 없다. 믿어서는 안된다. 우리는 지킬 수 있는 약속을 만들고, 이 약속을 믿어야 한다.

모두가 약속에 기반하여 코드를 짜면, 설령 시간이 흘러 사람이 변했더라도 약속을 상기시키며 수월하게 관리하고 변경할 수 있다. 클린코드는 그러한 약속들이라고 생각할 수 있다.

→ 너무나 공감되는 대목이었다. 코딩의 신의 강림한듯한 날에 마무리한 후 엄청난 뿌듯함을 가지고 퇴근을 했는데, 며칠 뒤에 다시 보니 너무나 복잡도가 높아 이해하기가 어려워 결국 바닥부터 다시 짰던 경험이 있다. 같은 맥락에서 팀 차원의 공용 컨벤션이나 아키텍처 구조 등을 가이드로 정하고 이를 지키려는 노력들이 중요하다고 생각한다.

Out → In vs In → Out : 처음부터 완벽한 코드는 없다

어떠한 도메인에 대한 구현을 하게 될 때 우리는 다음과 같은 접근 방식 중 하나를 선택하여 구현을 할 수 있다.

Out → In 방식

  • 도메인의 요구사항을 충족할 수 있을 정도로만 빠르게 구현한다.
  • 하나의 객체가 많은 역할을 수행하고 있을 수 있다.
  • 도메인 지식이 없거나 요구사항 복잡도가 높은 경우 적합하다.

In → Out 방식

  • 도메인을 가장 단위의 객체부터 나누어 기능을 구현하고, 이를 확장해 나간다.
  • 각 객체가 수행하는 역할이 작고 명확해진다.
  • 도메인 지식이 있거나 요구사항이 단순한 경우 적합하다.

→ 이 내용의 핵심은 어떤 방식이 더 낫다는 것이 아니라 상황에 맞게 적절하게 선택해야 한다는 것이다. 나의 경우 이러한 사실을 일찍 깨닫지 못하고, Out → In 방식이 클린 코드의 규칙들을 지키지 못하는 것 같다는 느낌이 들어서 새로운 도메인이 주어진 과제를 시작부터 In → Out 방식으로 접근했었다. 처음에는 작은 레벨 부터 객체를 확장시켜 나가면서 도메인의 요구사항을 충족하는게 어렵지 않았다. 그러나 점점 스펙이 추가 될 수록, 좋지 않는 냄새가 나는 코드가 되어져 갔다. (어? 굉장히 익숙한 상황 아닌가?) 왜냐하면 도메인 지식이 충분하지 않기 때문에 설계 과정에서 놓치는 점들이 많았기 때문이다. 결국 몇 번의 갈아엎음을 반복하면서 도메인 지식이 충분히 쌓인 후에야, 새로운 스펙이 추가 됐을 때 In - Out 방식으로 구현이 가능 해지기 시작했다.

프로그래밍의 대부분이 그런 것 같다. 모든 상황에 절대적으로 적용할 수 있는 만능 키는 없다고 본다. 다만, 각각의 선택에 대한 트레이드 오프를 인지하고 있고, 그것을 토대로 상황에 맞게 선택하는 지혜가 필요하다.

팀내에서 어떻게 도입할 것인가?

수업 내용을 듣더라도 당장 내일부터 팀에 적용하는 것은 무리가 있을 수 있다. 우리는 어떤 전략을 취해야 하는가?

사람은 감성적인 측면이 더 강하기 때문에 이성적, 논리적으로 제안하는 것보다 친분관계를 쌓고 실력에 대한 신뢰 형성 후 얘기를 나누어 보는 것이 더 승산이 있다.

우선은 내가 맡은 코드부터 바꿔보는 작은 시도를 하고 성공하는 경험을 느낀다. 그렇게 작은 시도와 성공을 반복하며 범위를 넓혀 가다가 관심이 보이는 동료가 있으면 같이 이야기를 나누며 전파하고, 팀원 전체에게 전파해 나간다. 만약 이러한 과정이 실패하더라도 내 성장이 따라오기 때문에 잃는 것은 없다. 시도해 볼만한 싸움이다.

→ 감성적인 측면으로 접근한다는 새로운 관점의 설득 방식이 기억에 남았다. 실제로 강력한 논리로 무장한 의견을 제시 하더라도 잘 받아 들여지지 않는 반면, 친하고 신뢰가 있는 동료의 몇 마디에 통과가 되는 상황을 본적이 있다. 전략을 성공시키기 위해서는 다양한 무기를 활용할 필요가 있다. 좋은 무기 중 하나가 되지 않을까?

강의를 듣기 전에…

내가 강의를 듣기 전에 궁금 했었던 부분의 정리이다.

클라이언트 개발자가 들어도 될까?

  • 결론적으로는 들어도 된다. 100% 자바 베이스로 [TDD, Refactoring, Clean Code] 에 대한 내용을 학습하는 것이기 때문에 개발 스택에 상관없이 내용을 따라갈 수 있다. 나는 처음 OT 시간에, 주변에 전부 백엔드 개발자분들만 계서서 수강대상이 아닌줄 알고 혼란에 빠졌던 기억이 난다.

    추가로 나는 처음 과제 진행을 할 때 기존에 익숙한 Android 스타일로 단계를 진행하다가, 관련된 피드백을 참조하여 익숙한 스타일을 깨고 다른 식으로 접근 해보기 위해 노력 했었다.

먼저 선수 과정으로 공부해야할 것이 있을까?

  • git, java 가 익숙하지 않은 사람은 기본적인 개념을 익히고 가면 좋다. 물론 각 과정에서 git, java 를 깊게 다루는 것은 아니기 때문에 진행하면서 같이 익혀도 상관은 없으나, 그만큼 초반에 더 많은 시간을 투자해야한다는 것을 염두해두자.
  • 객체지향의 오해와 진실, 이펙티브 자바, 클린 코드 등의 책을 읽고 가면 좀 더 빠른 이해와 과제 진행이 되지 않을까 싶다. 그러나 권장 사항일뿐 필수는 아니다.

정말 직장 다니면서 할 수 있을까?

  • 할 수 있다. 그런데 각오는 해야 한다. 퇴근 후에 지친 상태에서 과제를 진행 하는게 쉽지 않다. 하루종일 코드를 봤는데, 또 코드를 봐야 한다는 압박감도 찾아온다.

    또한 뒤로 갈수록 난이도가 올라가기 때문에, 점점 더 많은 시간의 투자가 요구된다. 평일과 주말을 가리지 않고 적지않은 시간을 투자 했는데, 갈수록 한주 내에 과제를 마무리하는 것이 쉽지 않았다. (글을 쓰는 이 시점에도 아직 마무리하지 못한 과제가 남아있다. 😂)

    물론 과제에 대한 마감 기한이 없고, 하나의 과제라도 확실히 내 것으로 가져가는 것이 중요하다고 하셨기 때문에 너무 무리해서 과제를 진행할 필요는 없다.

    나 같은 경우 지속적으로 하기 위해 일종의 보상 체계를 구축 했는데, 퇴근 후 딱 한시간 만 온전히 내가 하고 싶은 것을 하고 자기 전까지 과제를 진행하는 방법이었다. 효과는 굉장했다!

야근이나, 개인적인 사정으로 강의를 못듣게 되면 어떡하나?

  • 다음 기수에서도 똑같을지 모르겠으나, 내가 수강했던 기간에는 매번 강의가 끝난 후 촬영된 영상이 제공되었다. 또한 슬랙 DM 으로 언제든 궁금한 것을 물어볼 수 있기 때문에 갑작스러운 야근이나, 개인 사정으로 듣지 못하더라도 크게 걱정할 필요는 없다.

마무리 하며

이 수업 과정을 마쳤다고 해서 TDD, Clean Code, Refactoring 의 종착점에 도달했다고 얘기할 수는 없다. 오히려 이제 시작이라고, 나아가야 한다고, 출발선에 세워 주는 느낌이다. 달려가는 과정 속에서는 분명히 많은 어려움이 따라오겠지만 이제라도 올바른 레이스를 시작할 수 있게 된 것에 감사함을 느낀다. 글을 마무리하고, 미처 끝내지 못한 과제를 하러 다시 달려야겠다. 🤟

항상 열정적으로 강의를 진행해주신 재성님, 매번 디테일한 리뷰와 함께 함께 고민을 나누어 주셨던 리뷰어분들께 감사드린다.

link : https://edu.nextstep.camp/

다음 기수의 강의가 열리기 전에 미리 대기 신청을 할 수 있으므로, 관심이 있으면 클릭!

문제 상황

1.초기 브랜치 상황

// master
A

// old_feature
A - B - C - D

// new_feature (dependent old feature)
A - B - C - D - E - F - G - H

2. squash merge

이후 old_feature 가 squash merge 로 master 에 반영되었다.

// master
A - X

3. conflict 발생

new_feature 작업이 마무리된 후, 변경된 master 를 기준으로 rebase 하여 PR 을 날리려고 시도하였다.

기대한 브랜치

// new_feature (rebase from master)
A - X - E' - F' - G' - H'

그런데 conflict 가 발생하면서 old_feature 의 커밋 ‘A - D’ 를 순차적으로 resolve 처리해야하는 상황이 발생했다. master 의 커밋 ‘X’ 에 이미 old_feature 브랜치의 변경사항이 모두 반영되어 있는데, 왜 이런 일이 발생하는 것일까?

원인

원인을 알아보기 위해 Rebase 가 진행되는 과정을 살펴보도록 하자.

git rebase master

이렇게 아무런 옵션을 주지 않고 rebase 명령을 요청할 경우 다음과 같은 과정으로 진행된다.

  1. 체크아웃 된 브랜치와 master 브랜치가 나뉘기 전인 공통의 조상 커밋으로 이동한다.
  2. 공통 커밋부터 체크아웃 된 브랜치의 HEAD 까지 diff를 만든다.
  3. 만들어진 diff 를 순차적으로 적용한다.

위 과정을 베이스로 conflict 가 난 상황을 다시 재현 해보자.

  1. new_feature 를 master branch 를 통해 리베이스 한다.
  2. master 와 new_feature 의 공통 커밋인 A 로 이동한다.
    • old_feature 와 new_feature 의 공통 커밋은 D 이지만, 스쿼시 옵션으로 인해 old_feature 의 ‘B~D’ 커밋이 X로 통합 되었으므로 리베이스 과정에서는 D 를 찾을 수 없다.
  3. A 부터 new_feature 의 HEAD 까지 순차적으로 diff 를 적용한다.
  4. B,C,D 의 diff 를 반영할 때 X 에는 B,C,D 의 내용이 이미 반영되어있으므로 중복된 내용에 대해 다시conflict 가 발생하고 과정이 꼬이게 된다.

결론
squash 로 인해 기존의 공통 커밋이 새로운 커밋으로 통합되어 사라지게 되면서, rebase 과정에서 conflict 가 발생

해결

원인은 rebase 의 기본 동작을 수행했을 때 A 커밋부터 시작되는 것이었다. 그렇다면 old_feature 가 반영된 X 커밋의 이후 커밋부터 rebase 를 수행하도록 할 수 는 없을까?

다행히도 rebase 에서는 --onto 옵션을 제공하여 rebase 가 동작할 커밋 범위를 지정할 수 있다.

git rebase --onto master HEAD~4

위와 같이 요청할 경우 rebase 는 HEAD 를 포함하여 최근 4개의 커밋 범위까지 rebase 를 수행한다. 즉, new_feature 기준으로 E - F - G - H 범위 내에서 rebase 가 수행된다. master 에는 E~H 에 대한 내용이 없으므로 conflict 발생없이 한줄로 예쁘게 rebase 가 완료된다.

rebase 가 완료된 브랜치

// new_feature
A - X - E' - F' - G' - H'

번외

커밋 범위를 지정하는 것이 번거로울 경우 아래와 같은 방법으로 동일한 결과를 얻을 수 있다.

git rebase --onto master old_feature new_feature

해당 명령은 old_feature 에 존재하는 커밋을 제외하고, new_feature 에 대해 rebase 를 수행한다. 따라서 new_feature 에서, old_feature 에 있는 B-D 커밋은 제외하고, 그 뒤의 E-H 커밋에 대해서만 rebase 가 진행된다.

단, 해당 방법은 old_feature 가 삭제 되지 않았을 경우에만 사용이 가능하다는 제약이 있다.

이번 글에서는 데이터 중심 클래스의 정의와 단점을 알아보고, 이를 해결하기 위한 방법으로 캡슐화와 응집도/결합도에 대한 완전한 이해를 얻어가는 것이 목적이다.

데이터 중심으로 설계된 클래스

이전글 내용을 살펴보면 객체지향 애플리케이션을 구현하는 것은 각 객체가 책임을 수행하며 서로간의 협력을 통해 공동체를 구축한다는 것을 의미한다. 즉, 중요한 것은 객체가 다른 객체와 협력하는 방법이며, 각 객체가 내부에 어떤 상태를 가지고 관리하는 지에 대한 것은 부가적인 문제일 뿐이다.

그러나, 우리가 일반적으로 클래스를 설계하는 과정은 어떨까?

  1. 처음부터 객체간의 협력과 책임에 대해 고민하는 것은 머리아프다.
  2. 따라서 우선 클래스에 어떤 데이터가 들어가야할지 결정한다.
  3. 또한 어떤 상황에서도 객체가 사용될 수 있게 최대한 많은 getter/setter 를 추가한다.

이러한 설계방식을 데이터 중심 설계 (혹자는 추측에 의한 설계전략)라고 부르며, 데이터 중심 설계는 객체의 외부가 아닌 내부에 초점을 맞추게 된다.

데이터 중심 클래스의 객체는 단순한 데이터의 집합일 뿐이다. 따라서 제공되는 메소드는 과도한 접근자와 수정자 뿐이며, 이 데이터를 사용하여 로직을 수행하는 일은 별도의 객체에서 수행하게 된다. 이로 인하여 객체의 인터페이스에 내부 구현이 노출되고, 인터페이스를 이용하여 협력하는 또다른 객체 역시 데이터 중심 클래스의 내부구현에 종속되게 된다. (캡슐화가 이루어지지 않음) 문제는 객체의 내부 구현은 언제든 변화가 생길 수 있다는 점이다. 따라서 데이터 중심의 설계는 변화가 발생했을 때 협력하는 모든 객체가 영향을 받게되므로 변화에 매우 취약한 설계방식 이다.

캡슐화, 응집도, 결합도

좋은 객체지향 설계의 척도를 이야기할 때 빠지지 않고 등장하는 얘기들이 있다. 캡슐화가 잘되어야한다. 높은 응집도와 낮은 결합도를 가져야한다. 귀에 못박히도록 들었으나 해당 문장이 가지고 있는 진정한 의미를 이해하고 있었다고 단언할 수 있을까?

캡슐화

캡슐화가 무엇일까? 라고 질문하면 “객체 내부 구현을 외부로부터 감추는 것이다.” 라고 쉽게 얘기할 수 있다. 구현레벨에서는 접근 제한자를 이용하여 맴버변수를 숨기고 getter/setter 를 구성하며 캡슐화가 적용된 코드라고 얘기하곤 한다.

안타깝게도 위 내용은 캡슐화의 본질을 온전히 이해하고 있지 못하고 있다. 캡슐화는 분명 내부구현을 외부로부터 감추는 것이 맞다. 하지만 중요한 것은 왜 감추느냐는 것이다. 위에서 얘기한 데이터 중심의 설계방식을 살펴보면 그 이유를 알 수 있다. 캡슐화를 통해 감추는 이유는 내부구현을 숨김으로 인하여 내부구현의 변화가 발생하더라도 협력하는 외부 객체에 변화의 영향이 퍼져나가지 않도록 막기 위함이다.

너무 당연한 얘기일까? 그렇다면 다시 질문해보자. 접근제한자로 변수를 숨기고 getter/setter 를 통해 접근 및 조작하는 것은 캡슐화가 이루어진 것이라고 할 수 있을까? 캡슐화를 한 것은 맞다. 이러한 방식을 데이터 캡슐화라고 부른다. 그러나 데이터 캡슐화만으로는 캡슐화의 목적을 달성하지 못한다. getter 가 제공된다는 것은 필연적으로 어떤 로직을 수행하기 위해 해당 데이터가 필요하다는 의미이며, 그 로직은 또다른 객체에서 수행될 수 밖에없다. 그리고 만약 getter 의 return type 이 바뀐다면? 또다른 객체 역시 내용을 변경할 수 밖에 없다. 이러한 케이스를 객체의 내부구현이 드러난다고 표현한다. 캡슐화의 목적은 내부구현을 숨기는 것인데, 데이터 캡슐화만으로는 내부구현이 드러나게 되는 아이러니한 상황이 발생하는 것이다. (내부구현은 return type 뿐만 아니라 parameter, method 자체 로도 드러날 수 있다.)

// 캡슐화의 목적이 이루어지지 않은 클래스
public class Movie {
    private String title;

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }
}

자, 데이터를 숨기는 것만으로는 캡슐화의 목적이 달성될 수 없음을 알았다. 그렇다면 어떤 수준까지 해야할까? 책에서는 다음과 같이 얘기하고 있다.

다시한번 강조하지만 캡슐화란 변할 수 있는 어떤 것이라도 감추는 것이다. 그것이 속성의 타입이건, 할인 정책의 종류건 상관없이 내부 구현의 변경으로 인해 외부가 객체가 영향을 받는다면 캡슐화를 위반한 것이다.

변경될 수 있는 모든 것을 감추는 것, 그것만이 캡슐화의 목적에 다다를 수 있다. 그리고 이러한 규칙은 협력을 잘 구성하고 책임을 스스로 수행할 수 있도록 구성하면 자연스레 지켜지게 된다. 즉 캡슐화를 하기 위해 일련의 작업들을 수행하는 것이 아니라, 객체지향 프로그래밍의 규칙을 지키다보면 자연스럽게 캡슐화가 보장되어가는 형태가 된다.

그래도 역시 이해하기에 모호한 부분이 있다. 그래서 캡슐화가 잘 되었는지 파악할 수 있는 기준들을 소개하고자 한다.

응집도와 결합도

응집도와 결합도는 소프트웨어품질을 측정하기 위한 기준이지만 캡슐화의 정도를 측정할때도 유용하게 활용될 수 있다. 객체지향 관점에서 각 개념은 다음과 같이 풀어내고 있다.

  • 응집도는 클래스/객체에 얼마나 관련 높은 책임이 할당되었는지를 나타낸다.
  • 결합도는 클래스/객체가 협력에 필요한 적절한 수준의 관계만 유지하고 있는지 나타낸다.

… 객체지향을 오해하기 쉬운 이유는 이러한 애매하고 추상적인 개념들을 명확하게 이해하기가 어렵기 때문이라고 생각한다.

처음으로 돌아가 생각해보자. 좋은 설계란 무엇인가? 변경하기 쉬운 설계이다. 그리고 높은 응집도와 낮은 결합도를 가졌을 때 우리는 좋은 설계라고 부르곤한다. 따라서 각 개념은 결국 변경과 관련이 있다는 얘기가 된다. 변경의 관점에서 다시한번 각 개념을 살펴보자.

어플리케이션에 여러 모듈(객체)가 있을 때

  • (변경의 관점에서) 응집도는 변경이 발생했을 때 어플리케이션내에서 변경이 발생하는 정도를 의미한다. 예를들어 특정 기능에 대한 변경이 있을때 대해 하나의 모듈만 변경된다면 응집도가 높고, 다수의 모듈이 함께 변경돼야하면 응집도가 낮다고 할 수 있다.

    응집도가 낮다면 하나의 변경점에 대해 다수 모듈의 변경이 동반되기 때문에 변경에 부담이 생기기 시작한다.

  • (변경의 관점에서) 결합도는 하나의 모듈에서 변경이 발생했을 때, 다른 모듈의 변경을 요구하는 정도를 의미한다. 예를들어 하나의 모듈을 수정했을 때 함께 변경해야하는 모듈의 수가 많을 수록 결합도가 높다고 할 수 있다.

    결합도가 높다면 모듈의 내부구현이 변경되었을 때 영향을 받는 모듈 수가 많으므로 변경에 부담이 생기기 시작한다.

위 개념을 잘 이해했다면 왜 높은 응집도와 낮은 결합도가 충족된 것이 좋은 설계인것인지 알 수 있을 것이다.

위에서 살펴본 데이터 중심설계는 낮은 응집도와 높은 결합도를 가진 객체를 양산하기 쉬운 방식인데 이유는 다음과 같다.

응집도 개념에서 살펴보면, 객체의 클래스가 데이터를 중심으로 설계되었으므로 해당 객체의 책임은 데이터를 조작하고 내뱉는 것 그 이상도 이하도 아니다. 즉 데이터를 제공하는 객체가 스스로 일을 처리하지 못하므로, 로직을 수행하는 객체가 이를 수행하게 된다. 문제는 로직을 수행하는 객체가 또다른 데이터를 제공하는 객체를 맡기 시작할 경우 해당 객체의 책임은 점점 비대해지고 둘중 한쪽에서만 변경이 발생해도 필연적으로 같이 영향을 받게 된다. 하나의 클래스에서 변경이 발생했는데 전혀 연관이 없을 것같은 부분에서 사이드 이펙트가 발생하는 케이스가 대표적으로 각 클래스의 응집도가 낮은 상황이다.

결합도 개념에서 살펴보면, 마찬가지로 데이터를 제공하는 객체가 스스로 일을 처리하지 못하므로 해당 데이터를 다른 객체에서 처리하게 된다. 만약 해당 객체의 데이터가 변경될 경우 (parameter, type) 데이터를 사용하는 모든 객체에게 변화의 영향이 퍼지게 된다.

결국 내용을 돌아보면 다시 객체의 책임과 협력으로 귀결된다. 다음 글에서는 어떻게 객체에 책임을 잘 할당할 수 있는지에 대해 알아보자.

결론

  • 우리는 데이터 중심의 설계를 하고있었을 확률이 높다.
  • 캡슐화의 진정한 목적을 항상 기억하자.
  • 코드를 변경하기 어렵다면 응집도/결합도 정도를 점검해보자.
  • 결국 좋은 설계란 변경하기 쉬운 설계다.

변경은 언제든 찾아올 수 있다. 변경은 피할 수 없다. 이러한 사실을 깨달은 사람들은 변경에 유연하게 대응할 수 있는 방법을 고민해왔고 이에 다영한 해결법이 제시되었다. 이번에 다루게 될 객체지향 프로그래밍도 그 중 하나라고 할 수 있다.

객체지향 프로그래밍. 익숙하지만 제대로 이해하고 활용하고 있다고 얘기하기는 어려운 그런 녀석이었다. Java 를 사용하면 객체지향일까? 클래스 개념을 활용하면 객체지향일까? 안타깝게도 Java 내에서 class 를 사용해도 얼마든지 절차지향적인 코드가 나올 수 있고, 실제로도 그런 코드를 많이 양산해왔다. 객체지향적인 설계에 대한 고민이 깊어질때 즘 ‘객체지향의 사실과 오해’ 책으로 알게된 조영호님의 신간이 나왔음을 들었고 ‘오브젝트’ 책을 읽기 시작했다. 이번 포스팅은 챕터 1 ~ 3 을 읽고 정리하는 내용으로 채웠다.

객체간의 협력과 책임 (설계 레벨)

OOP 설계의 핵심은 협력을 구성하기 위해 적절한 객체를 찾고 적절한 책임을 할당하는 과정에서 드러난다. 클래스와 상속은 객체들의 책임과 협력이 어느 정도 자리를 잡은 후에 사용할 수 있는 구현 메커니즘일 뿐이다.

잘못된 방식

OOP 를 막 입문하면 가장 흔하게 하는 실수가 객체의 행동이 아닌 상태에 초점을 맞추는 것이다. 객체의 상태를 먼저 결정하고, 상태에 필요한 행동을 결정한다. 이런 방식은 객체의 내부 구현이 객체의 퍼블릭 인터페이스에 노출되도록 만들기 때문에 캡슐화를 저해한다. 또한 객체의 내부 구현은 언제든 바뀔 수 있기 때문에 결국 외부에 변화가 전파된다.

객체지향 관점에서 객체의 구성 방식

→ 객체는 어떤 협력을 참여하는가

→ 협력을 위해서 어떤 행동을 해야하는가

→ 행동하기 위해서 어떤 상태를 가져야하는가

1. 객체간의 협력과 수행해야할 책임을 파악할 것

어플리케이션안에 어떤 객체가 필요하다면 그 이유는 단 하나여야 한다. 객체가 어떤 협력에 참여하고 있기 때문이다. 그리고 객체가 협력에 참여할 수 있는 이유는 협력에 필요한 적절한 행동을 보유하고 있기 때문이다.

객체가 책임을 할당하는 데 필요한 메세지를 먼저 식별하고, 메세지를 처리할 객체를 나중에 선택하는 것이 중요하다. 이런 방식으로 객체를 구성할 경우 다음과 같은 이점이 있다.

  1. 객체가 최소한의 퍼블릭 인터페이스를 가질 수 있게 된다.
  2. 객체의 퍼블릭 인터페이스는 무엇을 하는지만 나타낼 뿐, 어떻게 수행하는지가 노출되지 않는다.

2. 책임을 수행하기 위해 필요한 상태 및 내부 구현 정의

객체의 퍼블릭 인터페이스가 정해졌다면, 다음으로는 퍼블릭 인터페이스로 들어온 요청을 객체 스스로 처리할 수 있도록 구성해야한다. 즉, 내부 구현 및 내부 구현에 필요한 상태의 정의가 필요하다. 객체의 상태는 그 객체가 행동을 수행하는 데 필요한 정보가 무엇인지로 결정된다. 객체는 자신의 상태를 스스로 결정하고 관리하는 자율적인 존재이기 때문이다.

협력관계속에서 다른 객체에게 무엇을 제공해야 하고 다른 객체로부터 무엇을 얻어야 하는지를 고민해야만 훌륭한 책임을 수확할 수 있다.

3. 책임을 조합하여 역할을 정의

어느정도 객체의 책임이 정의되었다면 객체의 역할을 정의할 수 있다. 코드레벨에서는 Java 기준으로 ‘interface, abstract class 를 구현한다.’ 라고도 얘기할 수 있다. 객체의 역할을 정의해주는 것이 중요한 이유는 동일한 협력을 수행하는 객체들을 추상화할 수 있기 때문이다. 협력관계가 역할이라는 추상화된 형태로 묶일 경우 당연히 변화에도 대응하기 쉬워진다. 책에서는 객체의 역할에 대해 다음과 같이 비유했다.

  • 서로 다른 배우들(객체)이 동일한 배역(역할)을 연기할 수 있다.
  • 하나의 배우(객체)가 서로 다른 배역(역할)을 연기할 수 있다.

협력이라는 문맥 안에서 역할은 특정한 협력에 참여해서 책임을 수행하는 객체의 일부다. 역할은 객체의 구조나 상태에 의해 정의될 수 없으며, 시스템의 문맥 안에서 무엇을 하는지에 의해서만 정의될 수 있음을 주의해야한다.

번외로, 오직 한 종류의 객체만 협력에 참여하는 상황에서 역할이라는 개념을 고려하는 것이 유용할까? 역할이라는 개념을 생략하고 직접 객체를 이용해 협력을 설계하는 것이 더 좋지 않을까? 라는 궁금증이 생길 수 있다. 책에서 내린 결론은 다음과 같다.

“협력에 적합한 책임을 수행하는 대상이 한 종류라면, 간단하게 객체를 이용하면 된다. 만약 여러 종류의 객체가 협력에 이용될 수 있다면 협력의 대상은 역할이 될 것이다.

하지만 설계 초반에는 적절한 책임과 협력의 큰 그림을 탐색하는 것이 가장 중요한 목표여야 하고 역할과 객체를 명확하게 구분하는 것은 그렇게 중요하지는 않다는 것이다.

즉, 상황에 맞게 역할(추상화된 형태) 혹은 객체 (구체적인 형태)를 잘 선택해서 사용하되, 어차피 바뀔 수 있는 내용이므로 설계 초기에는 크게 신경쓰지 않아도 된다고 이야기 하고 있다.

객체 지향 구현 기법 (구현 레벨)

캡슐화

클래스를 구현하거나 개발된 클래스를 사용할 때 가장 중요한 것은 클래스의 경계를 구분 짓는 것이다. 데이터와 기능을 객체 내부로 함께 묶는 것을 캡슐화라고 한다.캡슐화 된 객체는 상태는 숨기고 행동만 public 인터페이스로 공개한다.

객체의 외부와 내부를 구분하면 클래스를 사용하는 입장에서 알아야할 지식의 양이 줄어들고, 클래스 구현자는 내부 구현을 변경할 수 있는 폭이 넓어진다.

다형성

객체를 추상화된 형태로 제공할 수 있다. 추상화가 유연한 설계를 가능하게 하는 이유는 설계가 구체적인 상황에 결합되는 것을 방지하기 때문이다. (구체적인 상황은 항상 변경된다.)

주의할 점은 다형성을 이용하여 설계를 유연하게 가져갈수록 코드를 이해하고 디버깅하기는 점점 더 어려워진다. 무조건 유연한 설계도, 무조건 읽기 쉬운 코드도 정답이 아니다.

상속

상속이 가치있는 이유는 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있기 때문이다. (다형성 - 업캐스팅의 활용)

주의할 점으로 상속은 객체지향 프로그래밍에서 코드를 재사용하기 위해 널리 사용되는 되지만 두가지 관점에서 설계에 안좋은 영향을 미친다. (이펙티브 자바에서도 동일한 내용의 챕터가 존재한다.) ****

구현의 재사용성으로 이용된 상속은 변경에 취약하고, 부모클래스의 캡슐화를 깨기때문에 지양해야한다. 따라서 상속의 사용 목적은 구현의 재사용보다 인터페이스를 재사용하는 것에 초점을 맞춰야 한다.

하지만 실제 개발시에는 상속을 통해 내부 구현을 재사용해야할 일이 생기기 마련이다. 따라서 상황에 맞게 적절히 활용하는 지혜가 요구된다.

상황별로 권장되는 방법은 다음과 같다.

  • 구현내용 재사용 X, 다형성 활용 O : interface (Java)
  • 구현내용 재사용 O, 다형성 활용 O : abstract class (Java)
  • 구현내용 재사용 O, 다형성 활용 X : composition (디자인 패턴)

설계의 트레이드 오프

  • 어떤 기능을 설계하는 방법은 한가지 이상일 수 있다.
  • 동일한 기능을 한 가지 이상의 방법으로 설계할 수 있기 때문에 결국 설계는 트레이트 오프의 산물이다. 어떤 경우에도 모든 상황을 만족시킬 수 있는 설계를 만들 수는 없다.

구현과 관련된 모든 것들이 트레이드 오프의 대상이 될 수 있다. 작성하는 모든 코드에는 합당한 이유가 있어야 한다. 비록 아주 사소한 결정이더라도 트레이드 오프를 통해 얻어진 결론과 그렇지 않은 결론 사이의 차이는 크다.

보통 프로젝트를 새롭게 세팅 할경우, targetSdkVersion / compileSdkVersion / minSdkVersion 를 각각 지정할 수 있다. 세팅 이후에는 크게 건드릴 일이 없기에 평소에는 크게 신경쓰이지 않는 값이다.

그런데… 새로운 Google Play 방침 에 따르면 play store 에 등록되어 있는 앱을 업데이트 하거나, 새로 앱을 등록 할 경우 정해진 targetSdkVersion 을 만족해야 한다. 평소에 어렴풋하게 의미를 알고 있었는데 항상 헷갈려서 이번 기회에 정리 해보기로 했다.

targetSdkVersion 뿐만 아니라 compileSdkVersion, minSdkVersion 모두 안드로이드 API 버전과 관련된 값이므로 묶어서 한번에 다룰 예정이다.


targetSdkVersion

앱이 기기에서 동작할 때 사용되는 Android API 버전을 의미한다. (런타임) 따라서 실제 앱 동작에 영향을 주게 되므로 신중히 올려야 한다. 이때 기기의 Android OS API 버전과 혼동하여 헷갈리기 쉬우므로 각 케이스별로 분리하여 살펴보자.

앱은 기본적으로 targetSdkVersion 에 명시된 API 버전을 기준으로 동작한다. 예외적으로 기기 OS 버전이 낮아 아직 targetSdkVersion 의 API 버전을 지원하지 않을 경우 기기 OS 버전을 따라간다.

1. OS version > targetSdkVersion

  • 기기의 OS 버전 : API 26
  • 앱의 targetSdkVersion : API 24

해당 기기는 안드로이드 API 26 에서 제공하는 기능을 모두 사용할 수 있는 기기이다. 여기서 포인트는 사용할 수 있다는 것이지, 항상 해당 버전의 기능만 사용한다는 의미가 아니라는 것이다.

위 사례처럼 앱이 targetSdkVersion 값을 24 로 정했을 경우, 기기는 API 26 버전에서 제공하는 기능을 사용할 수 있지만 앱은 API 24 베이스로 동작한다.

2. OS version == targetSdkVersion

  • 기기의 OS 버전 : API 26
  • 앱의 targetSdkVersion : API 26

이 경우는 os 와 target 이 동일하므로, 앱이 해당 기기에서 API 26 버전 베이스로 동작한다.

3. OS version < targetSdkVersion

  • 기기의 OS 버전 : API 26
  • 앱의 targetSdkVersion : API 27

이 경우는 보통 국내 제조사들의 OS 업데이트가 늦기 때문에 발생될 수 있는 상황이다.

앱은 기기의 OS 버전인 API 26 베이스로 동작한다.


compileSdkVersion

컴파일 시 사용되는 Android API 버전을 의미한다. (컴파일 타임) 따라서 실제 개발중 사용할 수 있는 android API 범위는 compileSdkVersion 에 의해 결정된다. compileSdkVersion 값은 가급적 최신으로 유지하기를 권장하는데, targetSdkVersion 을 변경하지 않는 한 실제 배포되는 앱에 대한 사이드 이펙트가 없기 때문이다.

보통 최신 API 가 나오면 compileSdkVersion 을 먼저 올려서, 최신 API에 대한 대응이 완료된 후 targetSdkVersion 을 올린다. 만약 최신 버전 API 에서 새로 생긴 기능이 있고 이를 추가할 경우 warning 을 통해 하위 버전에서는 작동하지 않으므로 분기 처리를 요구한다.

추가로 gradle 내에 buildToolVersion 값이 있는데, 정상적인 빌드를 위해 complieSdkVersion 을 올릴 때 같이 최신버전으로 맞춰주는 것을 권장한다.


잠깐, 버전이 분할 되어 관리되어야 하는 이유는?

왜 안드로이드는 이렇게 각각 별도의 버전 정책을 둔 것일까?

이유를 유추해보면 다음과 같다. API 버전이 올라가면 Deprecated 되거나, 새롭게 추가된 것이 존재할 것이다. 이때 기기 os 버전을 기준으로 앱이 실행되도록 하면, 해당 버전이 대응이 안되어있던 앱들은 전부다 의도하지 않은 동작이 발생할 수 있다. 따라서 개발자들에게 앱이 컴파일타임과 런타임에 영향받는 API 버전을 각각 관리할 수 있도록 하여 위와 같은 문제를 예방하도록 하도록 한다.

최근에는 google 에서 많은 앱들이 targetSdkVersion 을 올리지 않아, 자신들이 의도한만큼 업데이트가 잘 반영되지 않고 이에 따라 좋지않은 사례들이 나오고 있음을 깨달았다. (최신 API는 당연히 이전 버전에서 문제가 되었거나 개선되어야할 점을 반영한 것이기 때문에 이전 API 보다 좋을 수 밖에 없고 빠르게 적용해주는 것이 좋다.) 이에 대한 대책으로 2018년 8월 이후로 target 을 구글이 명시한 최신버전으로 맞추지 않으면 playstore 에 업로드 및 업데이트를 할 수 없는 정책을 추가했다.

새로운 API가 나왔지만 아직 대응하기 어려울 경우, target 을 올리지만 않으면 된다. 하지만 되도록 최신버전은 바로 대응해주는 것이 일정관리 및 정신건강에 이롭다고 느낀다.


minSdkVersion

해당 앱을 구동할 수 있는 최소 커트라인이라고 이해하면 쉽다. 플랫폼의 OS 버전이 minSdkVersion 보다 낮을경우 앱이 설치되지 않는다.


API 버전 분기 처리와 SupportLibrary

우리는 사용자의 플랫폼이 어떤 버전을 사용하는지 미리 알 수 없기 때문에 버전별 API 변경 사항에 맞게 사용할 수 있도록 분기 처리를 해주어야 한다.

실제로 AndroidStudio 에서도 분기처리가 필요한 기능을 그냥 사용할 경우 warning 을 통해 알려주게 되는데 무시하고 진행해도 빌드는 정상적으로 되지만, 추후에 배포됐을 때 사용자 기기의 OS 버전에 따라 크래시가 발생할 수 있다.

애증의 minSdkVersion

분기 처리를 해야하는 버전의 범위는 minSdkVersion 을 기준으로 체크한다. minSdkVersion 은 앱이 구동될 수 있는 최소 요구 버전이므로, minSdkVersion 이 낮을수록 개발 시 대응해야 하는 버전이 많고, 높을수록 대응해야 하는 버전이 적다. 다양한 버전 대응을 위한 분기 처리가 많아질 수록 관리포인트가 늘어나고, 가독성도 떨어지게 되어 점점 보기 싫어지는 코드가 된다..

특히 알림 기능(Notification) 의 경우 특정 버전 이상에서만 되는 기능들이 굉장히 많은데 이를 모두 분기 처리하다보면 실제 로직을 파악하기 쉽지 않다.

개발자 입장에서는 minSdkVersion 값이 높을 수록 대응할 버전이 줄어드로 피로도가 적으므로, 앱의 minSdkVersion 이 높은 것이 회사 복지라는 우스갯소리도 있다. (우리도 조금만 더 올렸으면 좋겠다..)

그래도 보통 최대한 많은 고객에게 앱을 제공하기 위해 대부분 회사는 커버러지 99.8% ~ 99.9% 에 포함되는 버전까지는 대응하는 것으로 알고 있다.

SupportLibrary

위에서 살펴본 것 처럼 하위 호환성을 유지하기 위한 분기문으로 인해, 점점 코드의 가독성이 떨어지는 상황을 필연적으로 맞이하게 된다. 안드로이드에서는 다음 문제를 해결하기 위해 SupportLibrary 라는 대안을 내놓게 되었다.

서포트 라이브러리는 내부적으로 버전에 대한 분기 처리가 되어있어, 별도의 추가 작업없이 바로 기능을 수행하는 코드를 작성하면 된다.


Android 개발을 진행하다보면 Keyboard Height 를 알고싶은 상황을 마주할 때가 있다. 하지만 안타깝게도 Android에서 Keyboard Height 를 알 수 있는 native API 는 제공되지 않는다. 따라서 약간의 편법(?)을 통해 값을 알아내야 하는데, 오늘 포스팅에서는 그 과정을 다뤄보려 한다.

원리

Keyboard height 를 구하는 방법에 대한 다양한 접근법이 제시되고 있으나, 결국 핵심원리는 다음과 같다.

  1. 키보드가 없을 때 화면 전체의 높이를 구한다.
  2. Keyboard 가 올라왔을 때, Keyboard 가 가리고 있는 부분을 제외한 화면의 높이를 구한다.
  3. 1번 값 - 2번 값 = Keyboard Height

구현

화면이라는 단어는 자체는 매우 추상적이다. 위 원리의 핵심은 화면의 높이를 구하는 것이므로 코드 레벨의 사전 정의가 필요해보인다.
해당 포스트에서는 안드로이드에서 제공하고 있는 View 클래스를 화면으로 정의했다. 그 이유는 높이를 구하는 과정에서 활용할 수 있는 API 가 모두 View 에서 제공되기 때문이다.


1. 화면의 높이를 구한다.

View 는 getHeight() 메소드를 제공하기 때문에, 높이를 알아는 것이 어렵지 않다. 문제는 getHeight() 를 통해 얻어온 높이가 해당 View 의 정확한 높이라고 보장할 수 없다. 왜냐하면 View 의 높이는 초기화되자마자 결정되는 것이 아니라 일련의 측정 단계를 거쳐 결정이 되는데, 그 단계가 끝나기 전에 getHeight() 를 호출했을수도 있기 때문이다.

따라서 정확한 View 의 높이를 알기 위해서는 View 의 크기가 결정된 이후에 getHeight() 를 호출해야 한다.
View 에서 제공하는 ViewTreeObserver.OnGlobalLayoutListener 를 이용하면 결정되는 타이밍을 캐치할 수 있다.

OnGlobalLayoutListener 는 해당 View에 변경점이 생겼을 때, 이를 콜백형태로 감지할 수 있다. 변경되는 내용에는 View 의 크기가 결정되는 것도 포함되므로 우리는 해당 콜백이 호출되었을 때 View.getHeight() 를 호출하면 정확한 높이를 얻을 수 있다.

코드 레벨로는 다음과 같다.

1
2
3
4
5
6
7
8
int viewHeight = -1;

rootView.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
int currentViewHeight = rootView.getHeight();
if (currentViewHeight > viewHeight) {
viewHeight = currentViewHeight;
}
});

2. Keyboard 가 올라왔을 때, Keyboard 가 가리고 있는 부분을 제외한 화면의 높이를 구한다.

이 과정은 두가지 단계로 나누어 생각해보자.

  1. Keyboard 가 언제 올라왔는지 감지
  2. 현재 화면상에 보여지고 있는 영역의 높이 구하기

(1) Keyboard 가 언제 올라왔는지 감지

1단계는 다시 ViewTreeObserver.OnGlobalLayoutListener 를 이용하여 진행할 수 있다.

Keyboard 의 활성화/비활성화를 감지하는 아이디어는 다음과 같다.

  1. (SoftInputMode 값에 따라 다르지만) 일반적으로 Keyboard 가 올라오면 Keyboard 의 크기만큼 화면의 높이가 줄어들게 된다.
  2. 해당 화면(View)의 ViewTreeObserver 에 OnGlobalLayoutListener 를 등록할 경우 View의 높이가 변경될 때 이를 감지할 수 있다
  3. 따라서 높이가 변경되는 시점을 Keyboard 가 활성화/비활성화 된 상황이라고 추정할 수 있다.

View 를 이용할 수 있는 것은 알았다. 그럼 우리는 어떤 View 를 이용하여 위 과정을 진행해야할까?

1) Activity 의 RootLayout 을 이용한다.

가장 무난하고 쉬운 선택이다. Acitivity 의 Xml 에서 root level 에 있는 layout 을 View 로 이용하는 방법이다. 실제로도 대부분의 케이스에서는 잘 동작한다.

하지만 이 방법은 치명적인 단점이 존재한다. Activity 의 windowSoftInputMode 옵션 값에따라 정상적으로 동작하지 않을 수 있다는 것이다.

windowSoftInputMode 옵션은 가상 Keyboard 와 Activity 의 상호작용을 지정하는 옵션인데, ‘AdjustNothing’ 와 같은 일부 옵션값은 Keyboard 가 올라와도 Activity Height 의 변화가 없기 때문에 두번째 단계를 수행할 수 없다. 따라서 Acitivity 의 windowSoftInputMode 값에 따라 동일한 결과를 보장받지 못하기 때문에, 범용적으로 활용되기 어렵다.

2) PopupWindow 를 이용한다.

좀 더 개선된 선택지이다.

PopupWindow 는 고유의 SoftInputMode 을 별도로 지정할 수 있다. 따라서 Acitivity windowSoftInputMode 옵션이 무엇이든 영향을 받지 않고, 독립적으로 활용이 가능하다. 따라서 RootLayout 을 이용하는 1번 방식의 단점을 보완할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class KeyboardObserver extends PopupWindow {

private void initialize() {

setSoftInputMode(SOFT_INPUT_ADJUST_RESIZE | SOFT_INPUT_STATE_ALWAYS_VISIBLE);
setInputMethodMode(PopupWindow.INPUT_METHOD_NEEDED);

...

rootView.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
...
});
}
}
}

(2) 현재 화면상에 보여지고 있는 영역의 높이 구하기

2단계는 View 에서 제공하는 View.getWindowVisibleDisplayFrame() 를 활용하여 진행할 수 있다.

getWindowVisibleDisplayFrame(rect) 메소드는
해당 View 를 그리고 있는 window 를 기준으로, 현재 보여지고 있는 영역의 크기를 반환한다. 따라서, Keyboard가 올라왔을 때 해당 메소드를 호출할 경우 Keyboard로 인하여 가려진 영역을 제외한 화면의 높이를 얻을 수 있다.

1
2
3
4
Rect visibleFrameSize = new Rect();
view.getWindowVisibleDisplayFrame(visibleFrameSize);

int visibleFrameHeight = visibleFrameSize.bottom - visibleFrameSize.top;

3. Keyboard 의 높이 구하기

각 과정의 결과를 빼기면 하면 Keyboard 의 높이를 알 수 있다.

코드레벨로는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private int originHeight = -1;

rootView.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
getKeyboardHeight(rootView);
});

private int getKeyboardHeight(View targetView) {
if (targetView.getHeight() > originHeight) {
originHeight = targetView.getHeight();
}

Rect visibleFrameSize = new Rect();
rootView.getWindowVisibleDisplayFrame(visibleFrameSize);

int visibleFrameHeight = visibleFrameSize.bottom - visibleFrameSize.top;
int keyboardHeight = originHeight - visibleFrameHeight;

return keyboardHeight;
}

만약 PopupWindow 를 활용한다면 간략하게는 아래와 같이 구현할 수 있을 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class KeyboardHeightProvider extends PopupWindow {

...

private View rootView;
private int originHeight = -1;

...

private void initialize() {
// 개별 SoftInputMode 세팅
setSoftInputMode(SOFT_INPUT_ADJUST_RESIZE | SOFT_INPUT_STATE_ALWAYS_VISIBLE);

// Popup window 는 보이지 않아야 하므로 0 으로 세팅.
setWidth(0);
setHeight(MATCH_PARENT);

...

rootView.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
// callback, event 등으로 height 전달
getKeyboardHeight(rootView);
});
}

private int getKeyboardHeight(View targetView) {
if (targetView.getHeight() > originHeight) {
originHeight = targetView.getHeight();
}

Rect visibleFrameSize = new Rect();
rootView.getWindowVisibleDisplayFrame(visibleFrameSize);

int visibleFrameHeight = visibleFrameSize.bottom - visibleFrameSize.top;
int keyboardHeight = originHeight - visibleFrameHeight;

return keyboardHeight;
}
}

각 단계와 코드를 보면 알겠지만 그리 깔끔하게 구현되지는 않는다. 개인적으로 native API 를 지원해줬으면 하는 바램이다. 😹
위 코드를 그대로 사용하기 보다는 각 과정의 원리를 이해하고 프로젝트의 요구사항에 맞게 상세 구현을 채워나가는 방향으로 진행하면 될 것 같다.
실제 프로덕트 레벨에서는 아직까지 사용 시 큰 이슈가 없었으나 혹시 엣지 케이스가 보이거나 더 좋은 방법이 있다면 꼭 공유해주셨으면 좋겠다. 🙌

참고사항

  • 만약 PopupWindow 를 이용한다면, Activity 의 레퍼런스를 가지고 있을수 있기 때문에 메모리릭 예방차원에서 반드시 onPause/onStop 생명주기내에 해당 popupWindow 를 dismiss 해주는 것을 권장한다.
  • 위 방법에도 한계점은 당연히 존재한다. 바로, 한번도 Keyboard 를 활성화 시키지 않은 상태에서 Keyboard 의 높이를 구할 수는 없다는 것이다. 다른 방법이 있을까 하여 비슷하게 Keyboard 높이를 이용한 에니메이션이 적용된 카카오톡의 동작을 살펴보았으나, 카카오톡 역시 최초로 Keyboard 가 활성화되기 전까지는 Keyboard 의 높이와는 무관한 에니메이션으로 동작하는 것을 확인하였다. 디테일은 다를 수 있으나 기본적으로 높이를 구하는 원리는 비슷할 것으로 예상된다. 추후에 활성화 여부와 상관없이 구할 수 있는 방법을 알게 될 경우 따로 포스팅으로 공유하려 한다.
  • 위 구현은 화면의 Orientation 이 고려되어 있지 않다. 만약 화면 회전이 가능한 앱의 경우 각 orientation/높이를 key/value 로 하는 map 을 이용하여 관리하는 것도 하나의 방법이다.

RxJava 의 큰 장점 중 하나는 무엇이든 Observable 소스로 추상화하고, 이를 제공되는 Operation을 이용하여 손쉽게 가공할 수 있다는 점이다. 안드로이드 개발 중 가장 흔하게 접할 수 있는 케이스로는 Retrofit - RxJava 조합을 이용한 API 통신이 있다.

기본적인 API 콜 코드

1
2
3
4
MyService.fetchUserData()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(user -> updateViewFrom(user));

그런데 개발을 진행하다보면, 한번에 다수의 API 를 호출해야 할 상황을 맞이할 때가 있다. 역시 RxJava 에서 제공하는 다음 Operation 들을 활용하면 어려운 일이 아니다. 그러나 여전히 잘못 사용할 수 있는 여지가 존재한다. 기본적인 활용 방법과 주의해야할 점을 알아보자.

Multiple API call

보통 flatMap, merge, zip 3가지 옵션을 자주 활용하게 된다.

처음에 각 연산자의 역할과 차이점을 명확히 구별하기 어려울 수 있는데, 다음과 같은 예시로 먼저 가볍게 느낌만 알아보자.

어느 매장에서 치킨과 피자를 주문하였다. 이때 주문 옵션에 flatMap, merge, zip 을 선택할 수 있다.

  • 치킨.flatMap(피자) 옵션을 선택한 경우 : 치킨을 조리하고, 완성된 치킨을 이용하여 피자를 만든 후 피자가 제공된다.
  • merge.(치킨, 피자) 옵션을 선택한 경우 : 치킨, 피자 중 먼저 음식이 완성된 순서대로 제공된다.
  • zip.(치킨, 피자, 치킨 피자 세트) 옵션을 선택한 경우 : 치킨, 피자 둘다 음식이 완성되면 치킨 피자 세트가 제공된다.

1. API 콜 간에 의존성이 있다 : flatMap

각 API 호출 간에 의존성이 있는 케이스에서는 flatMap 을 활용하자. 예를 들면 첫번째 API 콜을 이용하여 인증 토큰을 얻어 오고, 인증 토큰을 이용하여 두번째 API 콜을 하는 케이스.

flatMap 연산자는 특수한 형태의 map 연산자이다. map 의 경우 다른 데이터 타입으로 가공하지만, flatMap 의 경우 다른 데이터를 발행할 수 있는 Observable 소스로 가공한다.

첫번째 API 를 호출하고, 그 결과를 이용하여 두번째 API 를 호출하는 코드

firstCall()
    .flatMap(firstCallResult -> secondCall(firstCallResult))
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(secondCallResult -> updateResult(secondCallResult))

2. 각각의 API 결과를 한곳에서 처리하고 싶다 : merge

API 가 서로 의존성이 없고, 각 결과를 하나의 옵저버에서 받고 싶을 경우 merge 를 활용하자.

merge 연산자는 여러 개의 Observable 소스에서 발행한 데이터를 모아서(merge) 한곳에서 모두 받을 수 있도록 해준다.

첫번째 API, 두번째 API 를 각각 호출하고 그 결과를 처리하는 코드

Observable.merge(firstCall(), secondCall())
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(eachResult -> updateResultWithIndividual(eachResult))

3. 각각의 API 의 결과를 조합하고 싶다 : zip

각각의 API 호출 결과를 모아서 한번에 받고 싶을 경우 zip 을 활용하자.

zip 연산자는 여러 개의 Observable 소스에서 발행한 데이터들을 모은 후, 모든 Observable 소스에서 데이터가 발행이 완료 됐을 경우 모았던 데이터를 결합하여 하나의 데이터 형태로 발행한다. 이때 각 결과를 어떻게 결합 할지에 대한 정의를 해줘야 한다.

첫번째 API, 두번째 API 결과를 합친 결과를 처리하는 코드

Observable.zip(
        firstCall(), 
        secondCall(), 
        (firstResult, secondResult) -> new combinedResult(firstResult, secondResult))
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(combinedResult -> updateResult(combinedResult)

그외..

위 내용에서는 자주 사용 되는 3개의 연산자만 설명했지만, 그 외에도 여러 특수한 케이스에서 활용할 수 있는 연산자가 많이 있다. 또한 API 호출이 아니라 Observable 소스로 추상화될 수 있는 그 무엇이든 위 연산자 들의 정의대로 활용이 가능하니 한번 익혀두면 두고두고 유용할 것이라 생각된다.

정말 효율적인 처리 일까? (Feat. 병렬처리)

이전 글에서도 얘기했지만, RxJava 는 같은 작업을 처리 하더라도 접근할 수 있는 경로가 매우 다양하다. 또한 각 연산자들이 어느정도 추상화되어있는 형태이다 보니 정확히 이해하지 않고 사용할 경우 결과는 그럴듯하나 내부적으로는 비효율적으로 동작할 수 있다.

사실은 위 예시에서도 비효율적으로 동작하는 부분이 존재한다. 다시 zip 을 활용하는 케이스로 돌아가보자. 우리가 zip 을 활용하여 API 콜을 묶을 때 기본적으로 다음과 같이 작동할 것이라고 생각한다.

“두개의 API가 각각 동시에 호출되고, 각 결과가 모두 도착하면 하나의 데이터로 발행이 되겠지?”

그런데 실제 API 콜을 프로파일러로 분석해보면, io 스케줄러 쓰레드에서 순차적으로 API 를 호출함을 알 수 있다. 즉, 비동기 처리는 되었지만 병렬로 동작하지 않게 된다.

비 효율적인 방법 (병렬처리 X)

Observable.zip(
        firstCall(), 
        secondCall(), 
        (firstResult, secondResult) -> new combinedResult(firstResult, secondResult))
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(combinedResult -> updateResult(combinedResult)

다음 코드를 보고 이런 궁금증이 생길 수 있다.

‘subscribeOn 에 io 스캐쥴러를 설정해줬으니, 가장 처음 수행되는 zip 이 io 쓰레드에서 동작하는거 아닌가?’

맞다. zip 연산 자체는 io 쓰레드에서 수행된다. 하지만 zip 의 정의를 다시 한번 생각해보자. zip 은 단지 2개 혹은 그 이상의 Observable 소스가 발행하는 데이터를 묶어서 하나의 데이터로 발행하는 역할을 수행할 뿐이지, 개별 Observable 소스의 아이템이 발행되는 쓰레드는 관여하지 않는다. 따라서 zip 연산자는 ios 쓰레드에서 순차적으로 Observable 소스를 발행 했던 것이다.

효율적인 방법 (병렬처리 O)

개별 Observable 소스가 각각 다른 쓰레드에서 아이템이 발행 되길 원할 경우 다음과 같이 소스에 스케쥴러를 각각 설정 해줘야한다.

Observable.zip(
        firstCall().subscribeOn(Schedulers.io()), 
        secondCall().subscribeOn(Schedulers.io()), 
        (firstResult, secondResult) -> new combinedResult(firstResult, secondResult))
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(combinedResult -> updateResult(combinedResult)

또한 zip 연산에 대한 스케쥴러 설정이 사라졌는데, 사실 zip 연산 자체가 별도의 쓰레드에서 수행될 이유는 없기 때문이다.

zip 연산외에 merge 에서도 위 내용은 동일하게 적용되니 개발시 참고하자!

맺으며

RxJava 는 강력한 도구이지만 그만큼 숙지해야할 내부 정책들도 제법 많다. 이러한 정책들, 낯선 접근방식 때문에 여전히 러닝커브가 높다고 여겨지지만 한번 적응하면 이만한 도구가 없다고 느껴지는 것도 사실이다. 잘 흡수하여 무기로 갈고 닦는다면 개발시 직면하는 다양한 문제들을 해결해줄 것이라고 생각한다. 🤟