2016년 9월 13일 화요일

릴리즈 모드로 빌드한 앱이 죽는다

이 글은 튜토리얼이나 API소개글이 아니라 실제로 겪었던 일을 토로(?)하기 위한 글이다. 내용은 제목 처럼 릴리즈 모드로 빌드한 앱을 돌려보면 죽는다는 것이고 이를 해결하기 위해 거친 고난과 해결법에 대해 소개한다.

. . .

Xcode 에서 Objective-C​를 이용해 (그리고 소수의 Swift 코드와 함께) 디버그 모드로 잘 개발해 왔고 당연히 디버그 모드에선 아무런 문제 없이 돌아가는 iOS용 앱 코드가 있었다. 이 코드는 간단한 개선 요구사항 덕분에 내부 구조를 완전히 망가뜨리다 못해 코어를 처음부터 완전히 새로 만들어야 했을 정도로 간단했던... 아아... 뭐 그래 하여간 좀 갈아 엎었다. 젠장.

그 덕분에 테스트 할 여지가 매우 많은 앱이었다.

물론 앞서 이야기 한 대로 디버그 모드로 빌드한 경우 별 다른 문제가 파악되지 않을 정도로 클린한 코드가 나왔다. 제대로 테스트 하기 위해서 assert 들도 잔뜩 박아 넣었다. 로그도 충분히 집어넣었다. 그야 말로 (처음 모습이 거의 남아있지 않을 정도로 내부는 많이 망가졌지만) 완벽하다고 생각되는 코드였다. 뭐... 약간 지저분하긴 했어.

하지만 문제는 릴리즈 모드로 빌드했을 때 발생했다. 디버그 모드로 빌드한 앱은 아무래도 좀 느리다보니 퍼포먼스 비교가 되지 않았다. 그래서 릴리즈 모드로 빌드해서 실행시켜 봤는데 시작부터 앱이 죽어나가기 시작했다.

자 과연 무엇이 문제일까. Xcode가 문제일까? 내 코드가 문제일까? 빌드 옵션이 문제일까?

릴리즈 모드에선 무엇이 다른가

대부분의 경우 디버그용 플래그들과 릴리즈(디플로이)용 플래그를 따로 관리하는 건 당연한 일일 것이다.

대부분의 디버그 모드 설정은 빌드 단계에서 디버그를 위한 정보 - 예를 들어 스트링테이블 같은 것들 - 들이 추가로 들어가게 만든다. 그리고 코드들이 최대한 순순히 쓰여진 대로 동작하기 위해 최적화 과정을 거치지 않고 컴파일된다. 따라서 덩치가 크고 느린 바이너리를 만들어 낸다.

반대로 릴리즈 모드의 경우 실행에 필요하지 않은 디버그 정보들을 걷어내는 작업 - 보통 스트립(strip)이라 부른다 - 을 하고, 사용자의 코드들을 컴파일러 단위의 최적화 과정을 거치게 된다. Swift의 경우라면 중간 언어 단계에서 최적화를 거치고 이를 바이너리로 컴파일 하는 단계를 거치면서 제법 속도가 빨라진다.

자 이제 차이를 알았으니 무엇에서 문제가 발생했는지 파악해야 할 차례다.

스트립 - 디버그용 정보를 걷어내는 과정 - 의 경우는 실행에 큰 지장을 주지 않는다. 단지 브레이크가 걸렸을 때의 심볼 정보를 제공하는게 목적이다. 따라서 앱을 죽게 만들 가능성은 희박하다고 생각한다.

그렇다면 최적화에 대한 것을 생각해야 할 것이다.

요즘의 Xcode는디버그 모드에선 -Onone, 릴리즈 모드에선 -O 즉 fast로 빌드하는 것이 기본 설정이다. 이 최적화 과정이 내 코드를 엉망으로 만드는 것이 아닐지 의심해 볼 수도 있다.


따라서 릴리즈 모드 설정에서 이 Fast 옵션을 None 으로 바꾸고 테스트 해 보면 이것이 원인인지는 파악될 것이다.

결과적으로 이 방법은 내 문제 해결에 도움을 주지 않았다. 여전히 앱은 죽어나가기 시작했고 이유를 전혀 알 수가 없었다.

아 잠깐, 그러고보니 빌드할 때 뭔가 이상한 현상을 본 적이 있다. 릴리즈 모드로 빌드하면 사용하지 않는 심볼이 있다는 경고들이 조금씩 보였다. 분명 사용되고 있는데 왜 그런지 이유는 모르겠고, 그냥 __unused 키워드를 이용해 경고문구들만 지워나갔다.

고난의 연속

이제 정말 고난이 시작되었다. 내가 생각 해 볼 만한 것은 시도해 봤으니 나머지 모르는 것들에서 문제가 있다고 밖에 생각이 들지 않았다.

결국 아무 생각없이 무식하게 Xcode 상에서 project settings를 열어서 디버그와 릴리즈의 옵션이 다른 경우를 하나하나 체크하기 시작했다. 아키텍쳐 부터 시작해서 스트립 설정 등등 다르기만 하면 무조건 하나씩 동일하게 옵션을 맞춰가며 실행시켜 봤다.

하지만 이 행군같은 일에 목적지가 도저히 보이지가 않았다. 앱은 여전히 죽어나갔다. 처참하게...

아무런 힌트도 없이 시간은 흘러갔다.

프로그램은 시간표일 뿐이다

소프트웨어를 만드는 개발자를 표현하는 다른 이름, 바로 프로그래머(Programmer)다. 프로그래머는 프로그램(Program)을 만드는 사람이다. 프로그램은 소프트웨어의 또다른 정의이다.

이 프로그램이라는 표현에서 우리는 중요한 것을 알아야 한다. 바로 시간표라는 의미다. 시간의 흐름에 맞게 순서대로 처리해야 할 일을 기록한 것이 바로 프로그램 이라는 말이다.

왜 이 말을 하냐고? 뭐긴 뭐야 한줄 한줄 따라가면서 디버깅 해 봐야 한다는 것이지. 프로그램이잖아.

그런데 릴리즈 모드에서만 죽는데 어떻게 라인 트레이싱을 할 거냐고? 안할거야. 대신 모든 함수나 메소드 마다 로그를 집어 넣었다. 로그를 보며 프로그램의 실행 과정을 지켜보는 지극히 무식하고 고전적이지만 가장 원초적인 디버깅 방법을 써 보기로 했다.

물론 코드를 고쳐야 한다는 건 큰 부담이기도 하다. 특히 이 경우 고친 양이 많은 만큼 나중에 문제가 해결되었을 때 다시 로그를 지워야 하니 귀차니즘이 배가 된다.

하지만 요즘은 git 의 시대. 마음대로 브랜치를 로컬에 따 두고 마음껏 수정해봐도 되잖아. 정말 좋은 세상이다.

자 시도해 보자.

용의자를 추려내다

로그를 이용한 원초적인 추적에서 중요한 현상을 발견했다. 실행되어야 할 메소드 몇 개가 호출되지 않고 있었다.

하지만 디버그 모드에서 그대로 돌려보면 해당 메소드들은 잘 호출되고 있었다.

드디어 용의자를 찾았다. 호출되지 않는 메소드. 그런데 왜 호출되지 않는 것이지?

그렇다면 코드를 호출하는 부분에서 뭔가 문제가 있는 셈이다. 거기를 찾아보자.
NSAssert(doSomething(), @"doSomething must return YES");
음... 분명 문제를 미리 발견하기 위해 assertion을 해 놓은 코드다.

왜 호출되지 않고 있는 것이지?

범인 검거

얼마전에 내가 썼던 글이 떠올랐다. Swift Assertion과 컴파일 최적화에 대한 글이다.

Assertion 의 용도는 문제를 사전에 발견하기 위해서 조건을 만족하지 않을 시 앱 실행을 중단시키고 해당 상황에서 문제가 있었으니 디버그하라고 해 주는 중요한 기능이다. 현명한 개발자라면 아마도 적절한 장소에서 적절하게 사용하고 있을 것이다.

그런데 Objective-C와 Swift에서 존재하는 (어쩌면 다른 컴파일이 필요한 언어들도 그럴지도 모르겠지만) 이 녀석의 중요한 특성 하나를 잊어먹고 있었다.

... Assertion 은 릴리즈 모드에서 생략된다 ...

분명 Swift 에서는 시험을 통해 사실을 파악하고 있었다. 하지만 Objective-C 에서 사용하는 NSAssert 조차도 이 특성을 가지고 있을 거라곤 생각지도 않았다.

결국 문제를 미연에 발견하기 위해 집어넣은 Assertion 코드들이 문제를 일으킨 주범이었다. 마치 범인 잡으라고 풀었더니만 범죄를 저지르는 현장을 목격하고도 무시하는 경찰 같았다.

이 녀석들을 검거하여 아래 처럼 바로잡아 주었다.
BOOL res = doSomething();
NSAssert(res, @"doSomething must return YES");
이렇게 하는 이유는 doSomething() 이 NSAssert 외부에서 실행되도록 하기 위함이다.

해결에 중요한 힌트를 놓쳤었다

앞서 릴리즈 모드와의 차이에 언급했던 이상한 현상을 다시 보자.

분명 디버그 모드에서는 별 다른 경고메시지 없이 빌드가 되었다. 하지만 릴리즈 모드로 빌드를 하면 사용하지 않는 변수가 있다는 경고가 떴다는 점 말이다.

이는 아래와 같은 식의 코드를 통해 발생할 수 있다.
int value;
NSAssert((value = doCompute()) != 0, @"doCompute() returned negative value");
위 코드에서 NSAssert 내부의 코드는 릴리즈 모드로 빌드 시 없는 것 처럼 컴파일이 된다. 따라서 릴리즈 모드로 빌드 시 value 라는 변수가 사용되지 않는다는 경고를 친절하게 표시해 준다.

변수가 아니라 함수도 그 대상이 될 수 있다.
BOOL someFunc() {
 ...
}

...

- (void)someWork {
  ...
  NSAssert(someFunc(), @"someFunc() must return YES");
  ...
}
약간 특수한 경우겠지만 의의 코드에서 만약 someFunc 함수를 호출하는 코드가 NSAssert 내부가 유일하다면 릴리즈 모드로 컴파일 시 사용하지 않는 함수에 대한 경고를 볼 수 있다.

만약 이 경고의 의미를 제대로 이해했다면 애초에 문제를 만들지 않았을 수도 있었다. 하지만 애초에 이런 특성을 모르고 있었다면 굉장히 고생을 할 수 밖에 없었을 것이다.

실수를 통해 배운다

일반적으로 전문가와 비전문가의 차이를 얼마나 잘하냐를 척도로 구분한다. 틀린 말은 아니다.

하지만 누군가는 이 둘의 차이를 이렇게 구분하다.

... 전문가는 비전문가보다 실패에 대한 경험이 많은 이들이다 ...

나도 이번의 실수를 통해 좀 더 전문가가 될 가능성이 높아졌으니 기뻐해야 한다는 말이다. 기쁘게 이 결과를 받아들이자.

물론 그렇다고 날려먹은 시간이 돌아오진 않겠지만... 훌쩍...

댓글 1개 :

jusung :

잘 보았습니다.
전문가는 다른 사람이 겪었던 + 겪을 실수들을 모두 해본사람이라는 이야기도 있더라구요.