앱 크래시
고친 사람 github-actions[bot]
앱 크래시는 앱이 처리하지 못한 오류 때문에 갑자기 꺼지는 일입니다. 사용자가 보던 화면이 예고 없이 사라집니다. 하던 작업도 함께 날아갑니다. 주로 모바일 앱과 데스크톱 앱에서 쓰는 말입니다. 실행 중인 프로그램 하나를 프로세스라고 합니다. 서버에서는 같은 일을 프로세스가 죽었다고 말합니다.
쉽고 빠른 이해
앱 크래시는 앱이 처리하지 못한 오류 때문에 앱을 돌리는 실행 환경이나 운영체제가 앱을 끝내는 일입니다. 비어 있는 값을 읽으려던 순간 보던 화면이 갑자기 사라지는 것이 흔한 모습입니다.
앱을 끝내는 까닭은 더 큰 피해를 막기 위해서입니다. 예상 못 한 오류 뒤에도 앱이 계속 돌면 틀린 값을 화면에 보여 주거나 저장할 수 있습니다.
어떻게 도는가:
- 함수 하나가 일을 계속할 수 없어서 오류를 던집니다
- 그 오류를 받아 주는 코드가 없으면 부른 함수로 계속 올라갑니다
- 맨 위까지 아무도 받지 않으면 앱 전체가 꺼집니다
대가는 사용자와 개발자가 나눠 집니다. 사용자는 하던 일을 잃습니다. 개발자는 고친 판을 내도 사용자가 앱을 업데이트할 때까지 같은 크래시를 계속 봅니다.
상세
앱 크래시는 앱이 처리하지 못한 오류 때문에 앱의 프로세스가 끝나 버리는 일입니다. 앱이 스스로 끝내는 것이 아닙니다. 앱 밑에서 앱을 돌리는 실행 환경이나 운영체제가 끝냅니다. 크래시는 영어 crash 를 소리 나는 대로 적은 말입니다. 사용자들은 흔히 「앱이 튕긴다」고 말합니다.
이 절은 오류가 앱을 끝내기까지의 길과 그 까닭, 원인, 모으는 법을 차례로 보고 끝에서 비슷한 실패와 견줍니다.
받아 주는 코드가 없는 예외
예외는 함수가 하던 일을 더 할 수 없을 때 그 사실을 알리려고 던지는 신호입니다. 목록에 둘밖에 없는데 세 번째를 꺼내라는 요청을 받은 함수가 그렇습니다. 함수는 틀린 값을 지어내 돌려주는 대신 예외를 던지고 멈춥니다.
던진 예외는 누군가 받아야 합니다. 자바와 Kotlin 에서는 try-catch 블록이 예외를 받는 코드입니다. 받은 코드는 오류를 화면에 알리거나 다른 길로 일을 이어 갑니다.
아래 코드는 받는 코드 없이 예외를 던지는 경우입니다.
val items = listOf("a", "b")
val third = items[2] // 예외: 범위 밖
println(third) // 실행 안 됨
items[2] 는 세 번째 칸을 꺼내라는 뜻입니다. 칸이 두 개뿐이라 이 줄에서 예외가 납니다. 다음 줄의 println 은 실행되지 않습니다.
이 코드를 감싼 try-catch 가 없으면 예외는 이 함수를 부른 함수로 올라갑니다. 함수가 함수를 부른 차례를 쌓아 둔 기록을 호출 스택이라고 합니다. 예외는 이 기록을 한 칸씩 거슬러 올라가며 받아 줄 코드를 찾습니다.
호출 스택은 스레드마다 하나씩 있습니다. 스레드는 프로세스 안에서 코드를 따로 실행하는 흐름 하나입니다. 예외가 올라갈 수 있는 끝은 그 스레드에서 맨 처음 불린 함수입니다.
그 끝까지 올라온 예외는 런타임이 넘겨받습니다. 런타임은 앱 코드 밑에서 메모리와 스레드를 관리하는 실행 환경입니다. 아래 그림은 예외가 런타임에 닿기까지의 길입니다.
flowchart TD
A["함수 안에서 예외가 난다"] --> B{"이 함수에 받는 코드가 있나"}
B -->|있다| C["거기서 처리하고 앱은 계속 돈다"]
B -->|없다| D{"스레드의 맨 위 함수인가"}
D -->|아니다| E["부른 함수로 한 칸 올라간다"]
E --> B
D -->|맞다| F["런타임이 앱 프로세스를 끝낸다"]
그림의 맨 아래 칸이 앱 크래시입니다. 안드로이드 앱에서는 앱 코드 어디에서도 예외를 받지 않으면 런타임이 앱 프로세스를 끝냅니다. 어느 스레드에서 올라온 예외든 앱 프로세스 전체가 끝납니다.
서버에서 도는 보통의 자바 프로그램은 다릅니다. 작업 스레드 하나에서 예외가 끝까지 올라와도 그 스레드만 끝나고 나머지는 계속 도는 경우가 많습니다. 런타임이 예외를 넘겨받았을 때 할 일을 정해 둔 기본 처리기가 다르기 때문입니다. 보통의 자바 런타임은 오류 내용을 찍고 그 스레드만 끝냅니다. 안드로이드는 앱이 시작될 때 프로세스 전체를 끝내는 기본 처리기를 깔아 둡니다.
운영체제가 끝내는 크래시
예외를 거치지 않고 운영체제가 직접 앱을 끝내는 크래시도 있습니다. 운영체제는 시그널이라는 짧은 알림을 프로세스에 보내 이 일을 합니다. 시그널을 받은 프로세스는 따로 준비해 둔 것이 없으면 바로 끝납니다.
대표적인 경우가 세그멘테이션 폴트입니다. 프로그램이 건드릴 권한이 없는 메모리 주소를 읽거나 쓰는 순간 납니다. 그러면 운영체제가 그 프로세스에 시그널을 보냅니다. 시그널을 받은 프로세스는 그대로 끝납니다.
세그멘테이션 폴트는 C 나 C++ 로 짠 코드에서 주로 생깁니다. 이런 코드를 흔히 네이티브 코드라고 부릅니다. 이 언어들은 메모리 주소를 직접 다루게 해 주지만 잘못 다뤄도 막아 주지 않습니다.
Swift 로 짠 iOS 앱도 시그널로 끝나는 길을 탑니다. 비어 있을 수 있는 값을 비었는지 확인하지 않고 꺼내 쓰거나 배열의 범위 밖을 읽으면, Swift 는 그 자리에서 프로그램을 멈추는 명령을 실행합니다. 그 명령이 운영체제의 시그널로 이어져 프로세스가 끝납니다. 이 멈춤은 try-catch 같은 코드로 받아 이어 갈 수 없습니다.
계속 돌지 않고 끝내는 까닭
받는 코드가 없는 예외를 만나면 런타임은 무시하고 넘어갈 수도 있었습니다. 그래도 끝내는 까닭은 앱의 상태를 더 믿을 수 없기 때문입니다.
송금 화면을 예로 들겠습니다. 보내는 사람 잔액을 줄인 직후 받는 사람 기록을 쓰기 전에 예외가 났다고 하겠습니다. 이대로 앱이 계속 돌면 잔액만 줄어든 틀린 화면이 보입니다. 그 틀린 값을 저장하거나 그 상태로 다음 요청을 보낼 수도 있습니다.
값이 반쯤만 바뀐 상태로 버티는 것보다 멈추는 편이 피해가 작습니다. 예상 못 한 오류에서 바로 멈추는 이 태도가 페일 패스트(fail-fast, 빨리 실패하기)입니다.
흔한 원인
크래시의 원인은 몇 갈래로 모입니다. 아래 표는 앱에서 자주 보는 것들입니다.
| 원인 | 어떻게 터지나 |
|---|---|
| 비어 있는 값 읽기 | 값이 없는 변수의 필드를 읽는다. 자바에서는 NullPointerException 이 난다 |
| 범위 밖 읽기 | 목록 길이보다 큰 번호로 칸을 꺼낸다 |
| 서버 응답의 모양이 바뀜 | 앱이 기대한 필드가 없거나 형이 달라 응답을 읽다가 예외가 난다 |
| 메모리 부족 | 큰 이미지를 한꺼번에 올려 앱에 허락된 메모리 한도를 넘긴다 |
| 잘못된 메모리 접근 | 네이티브 코드가 권한 없는 번지를 건드려 세그멘테이션 폴트가 난다 |
| 운영체제 판올림 | 새 판 운영체제에서 동작이 바뀐 기능을 예전 방식대로 부른다 |
첫 줄이 가장 흔해서 언어가 막아 주기도 합니다. Kotlin 과 Swift 는 비어 있을 수 있는 값을 타입에 따로 표시합니다. 그 값을 확인 없이 쓰면 컴파일러가 빌드를 막습니다. 이런 장치를 널 안전성이라고 부릅니다.
셋째 줄은 백엔드 개발자가 일으키기 쉬운 크래시입니다. 앱은 서버가 보내는 JSON(JavaScript Object Notation, 자바스크립트 객체 표기법) 응답을 객체로 바꿔 씁니다. 이 일을 역직렬화라고 합니다. 서버가 필드 하나를 빼거나 숫자를 문자열로 바꾸면 이 단계에서 예외가 납니다. 받는 코드가 없으면 앱이 꺼집니다.
남의 기기에서 난 크래시 모으기
서버 프로그램이 죽으면 개발자는 서버의 로그를 열어 봅니다. 앱 크래시는 사용자의 기기에서 납니다. 개발자는 그 기기를 볼 수 없어서 크래시 기록을 따로 모아 와야 합니다.
남의 기기에서 난 크래시 기록을 모아 오는 일을 크래시 리포팅(crash reporting)이라고 합니다. 이 일은 앱 안에 함께 넣는 라이브러리가 맡습니다. 라이브러리는 크래시가 나는 순간 무엇이 어디서 터졌는지를 기록합니다.
기록에는 스택 트레이스가 들어갑니다. 스택 트레이스는 크래시 순간 호출 스택에 쌓여 있던 함수들을 위에서부터 적은 목록입니다.
라이브러리는 이 기록을 크래시 순간 기기 저장소에 적어 둡니다. 그리고 다음에 앱을 켤 때 기록을 모으는 수집 서버로 보냅니다. 크래시 순간에 바로 보내지 않는 까닭은 끝나 가는 프로세스가 네트워크로 데이터를 보낼 시간을 믿을 수 없기 때문입니다. 아래 그림이 이 순서입니다.
sequenceDiagram
participant 앱
participant 저장소 as 기기 저장소
participant 서버 as 수집 서버
Note over 앱: 받는 코드가 없는 예외가 올라온다
앱->>저장소: 스택 트레이스와 기기 정보를 적는다
Note over 앱: 프로세스가 끝난다
Note over 앱: 사용자가 앱을 다시 켠다
저장소-->>앱: 지난번 기록을 꺼낸다
앱->>서버: 기록을 보낸다
수집 서버는 같은 줄에서 터진 크래시를 하나로 묶어 셉니다. 개발자는 어느 크래시가 사용자를 가장 많이 괴롭히는지 보고 고칠 차례를 정합니다.
모은 스택 트레이스를 바로 읽지 못할 때가 많습니다. 출시용 빌드는 크기를 줄이고 코드를 읽기 어렵게 하려고 함수 이름을 짧은 기호로 바꿉니다. 이 일을 난독화라고 합니다. 원래 이름을 되찾으려면 빌드할 때 함께 나온 대응표를 수집 서버에 올려 둬야 합니다.
앱이 얼마나 안정적인지는 대개 비율로 잽니다. 정해진 기간 동안 크래시를 한 번도 겪지 않은 사용자가 전체 사용자 가운데 몇인지 셉니다. 이 값이 크래시 없는 사용자 비율입니다.
비슷해 보이는 다른 종료
사용자 눈에는 앱이 사라지는 일이 다 같아 보입니다. 개발자에게는 누가 왜 앱을 끝냈는지에 따라 다른 일입니다. 앱 크래시와 자주 헷갈리는 것이 둘 있습니다.
ANR(Application Not Responding, 앱 응답 없음)은 앱이 멈춘 것처럼 보일 때 운영체제가 내리는 판정입니다. 오류가 난 것이 아니라 오래 걸리는 일이 화면을 맡은 스레드를 붙잡은 경우입니다.
메모리 회수는 기기의 메모리가 모자랄 때 운영체제가 뒤에 있던 앱을 끝내고 그 메모리를 거둬 가는 일입니다. 앞에 보이는 앱에 메모리를 주려는 것이라 앱 잘못이 아닐 수 있습니다. 리눅스 계열에서는 이 일을 OOM 킬러(Out Of Memory killer, 메모리 부족 킬러) 같은 장치가 맡습니다.
흔한 원인 표의 메모리 부족과 다른 점은 누구의 한도가 찼느냐입니다. 메모리 부족 크래시는 앱이 자기에게 허락된 한도를 넘겨 앱 안에서 오류가 난 것입니다. 메모리 회수는 앱이 한도 안에 있어도 기기 전체가 모자라서 운영체제가 거둬 가는 것입니다.
아래 표는 앱 크래시와 이 둘을 나란히 놓은 것입니다.
| 이름 | 누가 끝내나 | 왜 끝내나 | 사용자가 보는 모습 |
|---|---|---|---|
| 앱 크래시 | 앱의 런타임이나 운영체제 | 앱이 처리하지 못한 오류 | 화면이 갑자기 사라진다 |
| ANR | 운영체제 | 앱이 정해진 시간 안에 응답하지 않음 | 응답 없음 창이 뜬다 |
| 메모리 회수 | 운영체제 | 기기 전체의 메모리가 모자람 | 뒤에 있던 앱을 다시 열면 처음부터 뜬다 |
셋은 원인도 고치는 길도 달라서 따로 셉니다. 크래시는 오류가 난 줄을 고칩니다. ANR 은 오래 걸리는 일을 다른 스레드로 넘깁니다. 메모리 회수는 앱이 다시 켜졌을 때 하던 상태를 되살리도록 만듭니다.
서버의 프로세스 종료와 다른 점
서버에서도 프로세스는 죽습니다. 다만 서버는 죽은 프로세스를 대신할 장치를 겹겹이 둡니다. systemd 나 쿠버네티스 같은 관리 도구가 죽은 프로세스를 다시 띄웁니다. 로드 밸런서는 그동안 들어온 요청을 살아 있는 다른 서버로 보냅니다.
앱에는 이런 겹이 없습니다. 앱은 사용자 한 사람의 기기에서 하나만 돕니다. 그 하나가 죽으면 사용자가 직접 다시 켜야 합니다. 하던 일도 사용자가 잃습니다.
고친 코드가 퍼지는 속도도 다릅니다. 서버는 새 판을 배포하면 모든 요청이 바로 새 코드를 탑니다. 앱은 새 판을 앱 스토어에 올리고 사용자가 업데이트해야 합니다. 업데이트하지 않은 기기에서는 같은 크래시가 한동안 계속 납니다.
그래서 서버 쪽 변경이 옛 판 앱을 죽이지 않게 하는 일이 따라옵니다. 서버는 옛 판 앱이 기대하는 응답 모양을 한동안 지켜야 합니다. 이렇게 옛 판과 계속 맞물려 도는 성질을 하위 호환이라고 합니다.
새 판 앱을 내보낼 때도 크래시가 퍼지는 것을 막는 방법이 있습니다. 단계적 출시는 새 판을 전체 사용자가 아니라 일부 사용자에게 먼저 내보내는 방법입니다. 크래시가 늘면 거기서 출시를 멈춥니다. 문제가 모든 사용자에게 퍼지기 전에 막을 수 있습니다.
관련 항목
앱 크래시를 일으키는 오류
예외 · 처리되지 않은 예외 · NullPointerException · 널 포인터 역참조 · 인덱스 범위 초과 · OutOfMemoryError · 세그멘테이션 폴트 · 시그널 · 단언 · 메모리 누수
앱 크래시가 지나가는 실행 구조
프로세스 · 스레드 · 메인 스레드 · 호출 스택 · 스택 트레이스 · 런타임 · 운영체제 · 시그널 핸들러
앱 크래시를 막는 언어 장치와 원칙
try-catch · 널 안전성 · 옵셔널 · Kotlin · Swift · 페일 패스트 · 방어적 프로그래밍 · 역직렬화
앱 크래시를 모으고 읽는 도구
크래시 리포팅 · Firebase Crashlytics · Sentry · 난독화 · 심볼화 · 매핑 파일 · 크래시 없는 사용자 비율 · Android vitals
앱 크래시를 줄이는 출시 방법
단계적 출시 · 기능 플래그 · 하위 호환 · API 버전 관리 · 앱 스토어 심사 · 강제 업데이트
앱 크래시와 견주는 다른 앱 실패
ANR · OOM 킬러 · 메모리 부족 킬러 · 프레임 드롭 · 잰크 · 운영체제 판올림 파손
앱 크래시가 나는 분야와 플랫폼
모바일 개발 · Android · iOS · 데스크톱 애플리케이션
서버에서 죽은 프로세스를 받아내는 장치
프로세스 감독자 · systemd · Kubernetes · 헬스 체크 · 로드 밸런서 · 워치독 · 크래시 복구
다른 이름: app crash · 크래시 · 앱 튕김 · 앱 비정상 종료