사이드프로젝트 slecs

앱 보안·강제 업데이트·화면 성능을 한 사이클에 묶어 출시한 회고

목차

세 가지를 한 사이클에 묶어 출시한 건 처음부터 계획한 게 아니었다. 보안 강화, 강제 업데이트 체크, 메인 화면 성능 개선 - 원래는 따로따로 내려고 했는데, QA 라운드 비용을 계산해보니 세 번 돌리는 게 한 번 묶는 것보다 훨씬 비쌌다. 그래서 v3.1에 다 담았다. 결과적으로 검증 부담이 절반이 아니라 두 배로 늘어났지만, 그건 나중 이야기다.

보안 강화 - 난독화가 낳은 역풍

릴리스 빌드 설정부터 손봤다. 디버깅 흔적 노출, 난독화 미흡, 평문 트래픽 허용 같은 잔재들이다. 오랫동안 "나중에 정리하지"로 미뤄온 것들을 한 번에 정리했다.

적용 항목:

  • 릴리스 빌드 디버깅 비활성화
  • 코드 난독화 규칙 재정비
  • 평문 HTTP 통신 차단
  • 루팅 및 디버거 연결 감지 추가

평문 차단이랑 디버깅 비활성화는 별 탈 없었다. 문제는 난독화였다. R8 난독화를 켜자마자 직렬화 코드가 죽었다. 리플렉션으로 클래스명을 참조하던 부분이 빌드 후 전혀 다른 이름으로 바뀌어 있으니까. 로그를 봐도 a.b.c 같이 단축된 클래스명만 찍혀 있어서 어느 클래스가 문제인지 바로 못 잡았다. 원인 파악에 시간을 꽤 썼다.

결국 -keep 규칙을 패키지 단위가 아니라 인터페이스 경계만 좁게 잡는 방식으로 재작성해서 해결했다.

# 직렬화 대상만 좁게 유지
# 패키지 전체 keep은 난독화 효과를 통째로 날린다
-keepclassmembers class * implements com.example.Serializable {
    <fields>;
    <init>(...);
}

전체 패키지를 -keep으로 열어두면 난독화를 켠 의미가 없다. 리플렉션이 실제로 필요한 경계만 딱 잡는 게 맞는데, 이걸 처음부터 알았으면 반나절은 아꼈을 거다.

루팅·디버거 감지는 오탐 가능성을 항상 염두에 둬야 한다. 에뮬레이터, 특정 제조사 커스텀 ROM, 사내 테스트 장비에서 의도치 않게 감지 트리거가 걸리는 경우가 있다. 감지 결과를 즉시 앱 종료로 연결하지 않고 서버 로깅과 UX 경고 레이어로 분리해둔 게 다행이었다. 강경 대응은 데이터를 먼저 쌓고 판단하는 편이 훨씬 안전하다. 오탐 하나가 정상 사용자를 막는 사고로 이어지면 보안 강화가 오히려 독이 된다.

강제 업데이트 체크 - 실패 처리가 본론

앱 진입 시점에 현재 버전을 서버로 보내고, 응답으로 세 가지 중 하나를 받는 구조다.

케이스 동작 UX
강제 다이얼로그 + 스토어 이동 닫기 불가
권장 다이얼로그 + 나중에 선택 가능 스킵 가능
통과 무동작 평소 흐름

설계 자체는 단순한데, 진짜 신경 쓸 부분은 네트워크가 죽은 상황이었다. 체크 API 자체가 실패하면 어떻게 할 건가. 별도 처리 없이 놔두면 앱이 무한 로딩 상태로 굳어버리거나, 최악의 경우 진입 자체가 막히는 사고가 날 수 있다. 사용자 입장에서는 앱을 지우고 싶어지는 순간이다.

정책을 두 가지로 정했다. 타임아웃을 짧게 고정하고, 실패 시 강제·권장 없이 무조건 통과 처리. 업데이트 체크의 목적은 구버전 차단이지, 네트워크 장애로 정상 사용자를 막는 게 아니다. 실패 시 통과가 보안상 약점처럼 보일 수 있지만, 강제 차단의 가장 큰 위험은 서버 장애나 CDN 이슈가 생겼을 때 전체 사용자가 앱을 못 여는 상황이다. 그게 훨씬 더 나쁘다.

타임아웃 길이도 고민이 있었다. 너무 짧으면 느린 네트워크 환경에서 멀쩡한 사용자가 체크를 매번 스킵하게 되고, 너무 길면 앱 진입이 느려진다. 이 값은 "최적"이 없고 서비스 환경에 따라 다른 종류의 트레이드오프가 생긴다. 일단 짧게 잡고 로그로 실패율을 보면서 조정하는 쪽으로 결론 냈다.

권장 업데이트 스킵 횟수 제한이나 "며칠 후 다시 묻기" 같은 로직은 이번엔 넣지 않았다. 단순하게 가고, 운영하면서 필요하면 붙이는 방향으로 결론 냄.

성능 개선과 묶음 릴리스의 비용

체감상 느린 화면 두 개를 골라서 트레이스를 먼저 떴다. 전에 코드부터 건드렸다가 엉뚱한 곳만 최적화하고 아무것도 안 빨라진 경험이 있어서, 이번엔 측정부터 했다.

예상은 API 응답 지연이었는데 막상 보니 리스트 셀의 이미지 디코딩이 메인 스레드를 잡고 있었다. 스크롤마다 프레임이 울컥거리던 이유가 여기 있었음.

- 뷰 트리 깊이 4단계 → 2단계 (불필요한 래퍼 뷰 제거)
- 이미지 리사이즈·디코딩을 백그라운드 스레드로 이동
- 레이아웃 변경 없이 트리거되던 무효화 호출 제거

세 가지만 바꿨는데 첫 진입 체감이 절반 가까이 줄었다. 뷰 트리 깊이는 생각보다 자주 놓치는 부분이다. 컴포넌트 재사용 목적으로 래퍼를 쌓다 보면 금방 4~5단계가 되고, 레이아웃 패스 비용이 깊이에 비례해 올라간다. 주기적으로 한 번씩 뷰 계층을 펴주는 습관이 필요한데 바쁘면 계속 뒤로 밀린다.

이미지 리사이즈를 메인 스레드에서 하고 있었던 건 고치고 나서야 왜 놓쳤나 싶었다. 셀에 이미지를 보여주기 전에 표시 크기에 맞게 미리 리사이즈하고 캐싱하는 게 기본인데, 리사이즈 자체가 메인 스레드를 타고 있었다. 이미지가 클수록 프레임 드롭이 심해지는 구조였던 거다. "원인을 추측하지 말고 측정하라"는 말을 알면서도 트레이스 뜨는 게 귀찮아서 짐작으로 먼저 가고 싶은 충동이 생긴다. 이번에도 짐작은 다 틀렸다.

묶음 릴리스가 남긴 후유증은 생각보다 컸다. 세 가지를 한 사이클에 담으면 QA 횟수는 줄지만, 회귀 검증 범위는 곱으로 늘어난다. 보안 변경은 특히 회귀 범위가 기능 변경과 너무 달라서 같은 테스트 시트로 한 번에 돌리기가 어렵다. 결국 이번 릴리스에서 검증 라운드가 두 배로 늘었다. 난독화 이슈 하나 때문에 다른 두 기능이 같이 대기한 것처럼, 한 군데가 막히면 전체가 미뤄지는 리스크도 같이 따라온다.

다음부터는 보안성 변경은 별도 트랙으로 분리하기로 했다. 회귀 범위가 다른 변경끼리 묶으면 안 된다는 걸 이번에 다시 확인했다. QA 비용 절감 논리가 성립하더라도, 그 절감분보다 복잡도 증가 비용이 더 클 수 있다는 걸 몸으로 배운 사이클이었다.

댓글 0

첫 댓글 달아줘.