본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2026. 2. 3. · 4 Views
게임 보안과 치팅 방지 완벽 가이드
Flutter와 Flame 게임 엔진에서 클라이언트 보안부터 서버 검증까지, 치터들로부터 게임을 보호하는 핵심 기법을 다룹니다. 초급 개발자도 쉽게 따라할 수 있는 실전 보안 코드와 함께 설명합니다.
목차
1. 클라이언트 보안 기초
김개발 씨가 첫 모바일 게임을 출시한 지 일주일이 지났습니다. 그런데 리더보드를 확인해보니 이상한 점수들이 가득합니다.
분명 불가능한 점수인데, 어떻게 이런 일이 벌어진 걸까요?
클라이언트 보안은 게임이 실행되는 사용자 기기에서 데이터를 보호하는 것입니다. 마치 집 현관문에 자물쇠를 다는 것처럼, 외부 침입자가 게임 데이터를 함부로 조작하지 못하도록 막는 첫 번째 방어선입니다.
완벽한 보안은 없지만, 기초적인 보호막이라도 있어야 대부분의 일반적인 공격을 막을 수 있습니다.
다음 코드를 살펴봅시다.
// 게임 상태를 안전하게 관리하는 기본 클래스
class SecureGameState {
// private 변수로 외부 직접 접근 차단
int _score = 0;
int _lastUpdateTime = 0;
// getter를 통한 읽기만 허용
int get score => _score;
// 점수 업데이트 시 유효성 검증
bool updateScore(int delta, int timestamp) {
// 시간 역행 방지 체크
if (timestamp <= _lastUpdateTime) return false;
// 비정상적인 점수 증가 감지
if (delta > 100) return false;
_score += delta;
_lastUpdateTime = timestamp;
return true;
}
}
김개발 씨는 입사 6개월 차 게임 개발자입니다. 회사에서 첫 번째로 맡은 모바일 게임이 드디어 출시되었고, 처음 며칠은 모든 것이 순조로워 보였습니다.
그런데 일주일 후, 리더보드를 확인한 김개발 씨는 깜짝 놀랐습니다. 1위 점수가 무려 999,999,999점이었던 것입니다.
선배 개발자 박시니어 씨가 다가와 화면을 보더니 한숨을 쉬었습니다. "치터들이 벌써 들어왔군요.
클라이언트 보안 작업은 했어요?" 그렇다면 클라이언트 보안이란 정확히 무엇일까요? 쉽게 비유하자면, 클라이언트 보안은 마치 집 현관문의 자물쇠와 같습니다.
아무리 좋은 자물쇠도 전문 도둑을 완벽히 막을 수는 없지만, 자물쇠가 없는 집에 도둑이 들기가 훨씬 쉬운 것처럼, 기본적인 보안 장치라도 있어야 대부분의 침입 시도를 막을 수 있습니다. 클라이언트 보안은 바로 이 자물쇠 역할을 합니다.
클라이언트 보안이 없던 시절에는 어땠을까요? 과거에는 게임 데이터가 메모리에 그대로 노출되어 있었습니다.
치터들은 메모리 편집 도구를 사용해서 점수, 골드, 체력 같은 값을 마음대로 바꿀 수 있었습니다. 더 큰 문제는 이런 행위가 다른 플레이어들의 게임 경험을 완전히 망친다는 것이었습니다.
열심히 플레이한 사람이 바보가 되는 상황이 벌어지는 것입니다. 바로 이런 문제를 해결하기 위해 다양한 클라이언트 보안 기법이 등장했습니다.
가장 기본적인 방법은 private 변수를 사용하는 것입니다. Dart에서는 변수명 앞에 언더스코어(_)를 붙이면 해당 변수는 같은 라이브러리 내에서만 접근할 수 있습니다.
이렇게 하면 외부에서 직접 값을 변경하는 것이 어려워집니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 _score와 _lastUpdateTime 변수가 private으로 선언되어 있습니다. 이 변수들은 클래스 외부에서 직접 접근할 수 없습니다.
점수를 읽으려면 반드시 getter를 통해야 합니다. 그리고 updateScore 메서드에서는 두 가지 중요한 검증이 이루어집니다.
타임스탬프가 이전보다 작으면 거부하고, 한 번에 100점 이상 증가하면 의심스러운 것으로 판단합니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 러닝 게임을 개발한다고 가정해봅시다. 플레이어가 장애물을 피할 때마다 10점씩 얻는다면, 갑자기 1000점이 한꺼번에 추가되는 것은 분명히 비정상입니다.
이런 패턴을 감지해서 차단하는 것이 클라이언트 보안의 핵심입니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수는 클라이언트 보안만으로 충분하다고 생각하는 것입니다. 안타깝게도 클라이언트 측 코드는 결국 사용자의 기기에서 실행되기 때문에, 충분한 기술력을 가진 해커에게는 뚫릴 수 있습니다.
따라서 클라이언트 보안은 서버 검증과 함께 사용해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 곧바로 SecureGameState 클래스를 적용했습니다. 완벽하지는 않지만, 최소한 메모리 편집만으로 점수를 바꾸는 것은 어렵게 만들었습니다.
이것이 바로 보안의 첫걸음입니다.
실전 팁
💡 - 민감한 데이터는 항상 private 변수로 선언하고 getter/setter를 통해 접근하세요
- 값의 변경 시 반드시 유효성 검증 로직을 추가하세요
- 클라이언트 보안은 첫 번째 방어선일 뿐, 서버 검증과 병행해야 합니다
2. 데이터 암호화
김개발 씨가 게임의 저장 데이터를 확인해보다가 깜짝 놀랐습니다. JSON 파일을 열어보니 점수, 아이템, 골드가 누가 봐도 알 수 있게 그대로 적혀 있었던 것입니다.
이래서는 텍스트 편집기만으로도 데이터를 조작할 수 있겠군요.
데이터 암호화는 게임 데이터를 알아볼 수 없는 형태로 변환하는 것입니다. 마치 비밀 일기장에 암호를 사용해서 적는 것처럼, 설령 누군가 파일을 열어본다 해도 내용을 이해할 수 없게 만듭니다.
저장 데이터, 네트워크 통신, 메모리 내 중요 값 모두 암호화의 대상이 될 수 있습니다.
다음 코드를 살펴봅시다.
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:encrypt/encrypt.dart';
class GameDataEncryptor {
// 암호화 키 (실제로는 더 안전하게 관리해야 함)
final _key = Key.fromUtf8('my32lengthsupersecretnooneknows1');
final _iv = IV.fromLength(16);
// 게임 데이터 암호화
String encryptData(Map<String, dynamic> gameData) {
final encrypter = Encrypter(AES(_key));
final jsonString = jsonEncode(gameData);
return encrypter.encrypt(jsonString, iv: _iv).base64;
}
// 게임 데이터 복호화
Map<String, dynamic> decryptData(String encrypted) {
final encrypter = Encrypter(AES(_key));
final decrypted = encrypter.decrypt64(encrypted, iv: _iv);
return jsonDecode(decrypted);
}
}
김개발 씨는 저장 시스템을 구현한 뒤 뿌듯한 마음으로 저장 파일을 열어보았습니다. 그런데 화면에 나타난 내용을 보고 얼굴이 하얗게 질렸습니다.
"score": 1500, "gold": 25000, "items": ["sword", "shield"]... 모든 것이 너무나 명확하게 보였던 것입니다.
박시니어 씨가 지나가다 화면을 보더니 고개를 저었습니다. "이건 치터들에게 '마음껏 수정하세요'라고 초대장을 보내는 거나 마찬가지예요.
암호화가 필요합니다." 그렇다면 데이터 암호화란 정확히 무엇일까요? 쉽게 비유하자면, 암호화는 마치 외국어로 일기를 쓰는 것과 같습니다.
아무리 누군가 일기장을 훔쳐봐도, 그 언어를 모르면 내용을 이해할 수 없는 것처럼, 암호화된 데이터는 복호화 키가 없으면 의미 없는 문자열에 불과합니다. 게임에서는 이 기법을 사용해서 저장 데이터, 통신 내용, 심지어 메모리에 있는 값까지 보호합니다.
암호화가 없던 시절에는 어땠을까요? 과거의 게임들은 저장 파일을 단순 텍스트나 쉽게 해독 가능한 형태로 저장했습니다.
치터들은 메모장으로 파일을 열어서 골드를 9999999로 바꾸거나, 모든 아이템을 소유한 것처럼 수정할 수 있었습니다. 온라인 게임에서는 네트워크 패킷을 가로채서 내용을 변조하는 것도 가능했습니다.
바로 이런 문제를 해결하기 위해 암호화 기술이 게임에 적용되기 시작했습니다. **AES(Advanced Encryption Standard)**는 현재 가장 널리 사용되는 대칭 키 암호화 알고리즘입니다.
미국 정부에서도 사용할 만큼 안전하며, Dart에서는 encrypt 패키지를 통해 쉽게 구현할 수 있습니다. 대칭 키라는 것은 암호화와 복호화에 같은 키를 사용한다는 의미입니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 _key는 32바이트 길이의 비밀 키입니다.
이 키를 알아야만 데이터를 복호화할 수 있습니다. _iv는 초기화 벡터로, 같은 데이터를 암호화해도 매번 다른 결과가 나오게 만들어줍니다.
encryptData 메서드는 게임 데이터를 JSON으로 변환한 뒤 암호화하고, decryptData는 그 반대 작업을 수행합니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 RPG 게임의 캐릭터 저장 시스템을 생각해봅시다. 레벨, 경험치, 장비, 스킬 포인트 등 모든 정보를 암호화해서 저장하면, 사용자가 파일을 열어봐도 "aGVsbG8gd29ybGQ=" 같은 알 수 없는 문자열만 보입니다.
이것만으로도 캐주얼한 치팅 시도의 대부분을 막을 수 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수는 암호화 키를 코드에 하드코딩하는 것입니다. 위의 예제에서도 키가 코드에 직접 적혀 있는데, 이는 설명을 위한 것이고 실제로는 매우 위험합니다.
앱을 디컴파일하면 키가 노출되기 때문입니다. 실제 프로젝트에서는 키를 안전하게 관리하는 별도의 방법이 필요합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 암호화를 적용한 뒤 저장 파일을 다시 열어보니, 이제는 완전히 알아볼 수 없는 문자열만 가득했습니다.
"이제 텍스트 편집기로는 절대 못 바꾸겠네요!" 김개발 씨는 안도의 한숨을 내쉬었습니다.
실전 팁
💡 - 암호화 키는 절대 코드에 하드코딩하지 말고, 안전한 저장소나 서버에서 관리하세요
- AES-256 같은 검증된 표준 알고리즘을 사용하세요
- 저장 데이터뿐 아니라 네트워크 통신도 반드시 암호화해야 합니다
3. 서버 검증
김개발 씨가 암호화까지 적용했는데도, 여전히 비정상적인 점수가 리더보드에 올라오고 있었습니다. 어떻게 된 일일까요?
박시니어 씨가 핵심을 짚어주었습니다. "클라이언트는 믿으면 안 돼요.
서버가 직접 확인해야 합니다."
서버 검증은 게임의 모든 중요한 판단을 서버에서 수행하는 것입니다. 마치 시험 감독관이 학생의 답안지를 직접 확인하는 것처럼, 클라이언트가 보내온 데이터를 서버가 다시 한번 검증합니다.
클라이언트는 해킹당할 수 있지만, 서버는 개발자가 완전히 통제할 수 있기 때문에 가장 신뢰할 수 있는 방어선입니다.
다음 코드를 살펴봅시다.
// 클라이언트에서 서버로 점수 전송
class GameApiClient {
Future<bool> submitScore(int score, List<GameAction> actions) async {
// 게임 플레이 기록과 함께 전송
final response = await http.post(
Uri.parse('https://api.game.com/score'),
body: jsonEncode({
'score': score,
'actions': actions.map((a) => a.toJson()).toList(),
'timestamp': DateTime.now().millisecondsSinceEpoch,
'checksum': _calculateChecksum(score, actions),
}),
);
return response.statusCode == 200;
}
// 체크섬으로 데이터 무결성 확인
String _calculateChecksum(int score, List<GameAction> actions) {
final data = '$score:${actions.length}:${_secretSalt}';
return sha256.convert(utf8.encode(data)).toString();
}
}
김개발 씨는 고개를 갸웃거렸습니다. "분명히 암호화도 하고 클라이언트 보안도 적용했는데, 어떻게 아직도 치팅이 가능한 거죠?" 박시니어 씨가 차분하게 설명했습니다.
"클라이언트 보안의 한계예요. 아무리 잘 만들어도, 결국 사용자 기기에서 실행되는 코드는 뚫릴 수 있어요.
진짜 중요한 건 서버에서 검증하는 겁니다." 그렇다면 서버 검증이란 정확히 무엇일까요? 쉽게 비유하자면, 서버 검증은 마치 은행의 거래 확인 시스템과 같습니다.
고객이 "100만원을 입금했어요"라고 말한다고 해서 은행이 그냥 믿지는 않습니다. 실제로 돈이 들어왔는지 시스템에서 직접 확인하고 나서야 잔액을 올려주는 것처럼, 게임 서버도 클라이언트가 보낸 점수를 그대로 믿지 않고 검증 과정을 거칩니다.
서버 검증이 없던 시절에는 어땠을까요? 과거의 많은 게임들은 클라이언트를 너무 신뢰했습니다.
"점수가 1000점입니다"라는 메시지를 받으면 그대로 저장했습니다. 치터들은 이 점을 악용해서 조작된 점수를 서버에 보냈고, 서버는 아무런 의심 없이 그 점수를 기록했습니다.
결과적으로 리더보드는 불가능한 점수들로 가득 찼습니다. 바로 이런 문제를 해결하기 위해 서버 검증이 도입되었습니다.
핵심 원칙은 간단합니다. 클라이언트는 절대 신뢰하지 않는다는 것입니다.
점수만 보내는 것이 아니라, 그 점수가 어떻게 나왔는지 게임 플레이 기록을 함께 보냅니다. 서버는 이 기록을 분석해서 실제로 그 점수가 나올 수 있는지 시뮬레이션합니다.
위의 코드를 한 줄씩 살펴보겠습니다. submitScore 메서드는 단순히 점수만 보내지 않습니다.
actions 리스트에는 플레이어가 수행한 모든 행동이 담겨 있습니다. 점프를 몇 번 했는지, 아이템을 언제 사용했는지, 적을 몇 마리 처치했는지 등의 정보입니다.
checksum은 데이터가 중간에 변조되지 않았는지 확인하는 장치입니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 퍼즐 게임을 개발한다고 가정해봅시다. 클라이언트가 "30초 만에 클리어, 5000점"이라고 보내왔다면, 서버는 해당 퍼즐의 최소 이동 횟수와 각 이동에 필요한 최소 시간을 계산합니다.
이론적으로 불가능한 시간이라면 치팅으로 판단하고 점수를 거부합니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수는 모든 것을 서버에서 처리하려는 것입니다. 서버 부하와 비용을 고려해야 합니다.
모든 프레임의 상태를 서버로 보내면 네트워크 비용이 엄청나게 늘어납니다. 따라서 중요한 이벤트(점수 획득, 아이템 사용, 게임 종료 등)만 선별적으로 검증하는 전략이 필요합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 서버 검증을 도입한 후, 비정상적인 점수가 현저히 줄었습니다.
물론 완벽하지는 않지만, 이제 치터들은 단순히 점수를 조작하는 것만으로는 부정행위를 할 수 없게 되었습니다. "서버가 감독관 역할을 하는 거군요!" 김개발 씨가 말했습니다.
실전 팁
💡 - "클라이언트는 절대 신뢰하지 않는다"를 항상 기억하세요
- 점수뿐 아니라 플레이 로그를 함께 보내서 재현 가능하게 만드세요
- 서버 부하를 고려해서 중요한 이벤트만 선별적으로 검증하세요
4. 치트 탐지 알고리즘
서버 검증을 도입했지만, 교묘한 치터들은 여전히 빠져나가고 있었습니다. 박시니어 씨가 새로운 과제를 제시했습니다.
"이제 패턴을 분석해서 치터를 찾아내는 알고리즘이 필요해요. 통계적으로 불가능한 행동을 감지하는 거죠."
치트 탐지 알고리즘은 플레이어의 행동 패턴을 분석하여 비정상적인 플레이를 감지하는 것입니다. 마치 은행의 이상거래 탐지 시스템처럼, 정상적인 플레이어의 패턴을 학습하고 이와 크게 벗어나는 행동을 자동으로 찾아냅니다.
단순히 불가능한 값만 걸러내는 것이 아니라, 통계적으로 의심스러운 행동까지 잡아낼 수 있습니다.
다음 코드를 살펴봅시다.
class CheatDetector {
// 플레이어 행동 분석
Future<CheatReport> analyzePlayer(String playerId) async {
final recentGames = await _getRecentGames(playerId, limit: 100);
final report = CheatReport(playerId: playerId);
// 비정상적인 반응속도 체크
final avgReactionTime = _calculateAvgReactionTime(recentGames);
if (avgReactionTime < 50) { // 50ms 미만은 인간 한계 초과
report.addSuspicion(CheatType.speedHack, confidence: 0.9);
}
// 점수 분포 이상 체크
final scoreVariance = _calculateScoreVariance(recentGames);
if (scoreVariance < 0.01) { // 점수가 너무 일정함
report.addSuspicion(CheatType.scoreManipulation, confidence: 0.7);
}
// 플레이 시간 대비 성장 속도 체크
final growthRate = _calculateGrowthRate(recentGames);
if (growthRate > normalGrowthRate * 3) {
report.addSuspicion(CheatType.autoPlay, confidence: 0.8);
}
return report;
}
}
김개발 씨는 데이터를 분석하다가 이상한 점을 발견했습니다. 한 플레이어가 100판을 연속으로 플레이했는데, 모든 판의 점수가 정확히 동일했습니다.
기계처럼 완벽하게 같은 점수라니, 이건 분명히 뭔가 이상합니다. 박시니어 씨가 고개를 끄덕였습니다.
"좋은 관찰이에요. 인간은 완벽할 수 없거든요.
매번 조금씩 실수하고, 조금씩 다른 결과가 나오는 게 정상이에요. 저런 패턴은 자동화 프로그램의 전형적인 특징이에요." 그렇다면 치트 탐지 알고리즘이란 정확히 무엇일까요?
쉽게 비유하자면, 치트 탐지는 마치 필적 감정사의 일과 같습니다. 사람마다 글씨체에 고유한 특징이 있듯이, 게이머들도 각자의 플레이 스타일이 있습니다.
필적 감정사가 위조 문서를 찾아내듯, 치트 탐지 알고리즘은 비정상적인 플레이 패턴을 찾아냅니다. 치트 탐지가 없던 시절에는 어땠을까요?
과거에는 운영자가 직접 신고를 받고 수동으로 확인했습니다. 하지만 사용자가 많아지면 이런 방식은 한계에 부딪힙니다.
또한 교묘한 치터들은 단순한 규칙 기반 검증을 피해갈 수 있었습니다. 예를 들어 "점수가 10만점을 넘으면 차단"이라는 규칙이 있다면, 9만9천점만 찍고 멈추는 식이었습니다.
바로 이런 문제를 해결하기 위해 통계 기반 치트 탐지가 도입되었습니다. 핵심은 정상적인 플레이어의 패턴을 먼저 정의하고, 이와 크게 벗어나는 행동을 감지하는 것입니다.
인간의 반응 속도는 보통 150~300ms 사이입니다. 50ms 미만의 반응 속도가 지속적으로 나타난다면 이것은 인간의 한계를 넘어선 것입니다.
또한 점수의 분산이 너무 낮다면(항상 비슷한 점수), 자동화 도구를 의심할 수 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.
analyzePlayer 메서드는 최근 100경기의 데이터를 가져와 분석합니다. avgReactionTime이 50ms 미만이면 스피드핵 의심으로 표시합니다.
scoreVariance가 너무 낮으면 점수 조작 의심입니다. growthRate가 정상의 3배를 넘으면 자동 플레이를 의심합니다.
각 의심에는 confidence 값을 붙여서 얼마나 확신하는지도 기록합니다. 실제 현업에서는 어떻게 활용할까요?
대형 게임 회사들은 머신러닝을 활용한 더 정교한 탐지 시스템을 운영합니다. 수백만 플레이어의 데이터를 학습해서 정상 패턴을 모델링하고, 이와 다른 행동을 자동으로 플래그합니다.
이런 시스템은 새로운 유형의 치팅도 감지할 수 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수는 오탐(false positive)을 고려하지 않는 것입니다. 실력이 정말 뛰어난 프로 게이머도 있을 수 있습니다.
무고한 플레이어를 치터로 몰아서는 안 됩니다. 따라서 자동 차단보다는 일정 수준의 의심이 쌓이면 수동 검토를 거치는 것이 안전합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 치트 탐지 알고리즘을 도입한 후, 이전에는 발견하지 못했던 교묘한 치터들이 속속 잡히기 시작했습니다.
"데이터는 거짓말을 하지 않는군요." 김개발 씨가 감탄했습니다.
실전 팁
💡 - 인간의 물리적 한계(반응속도, 클릭 속도 등)를 기준으로 설정하세요
- 오탐을 방지하기 위해 자동 차단보다는 검토 대상으로 분류하세요
- 정상 플레이어의 데이터를 충분히 수집해서 기준선을 정확히 설정하세요
5. 코드 난독화
김개발 씨가 보안 컨퍼런스에 다녀온 후 충격을 받았습니다. 발표자가 앱을 디컴파일해서 소스코드를 거의 원본에 가깝게 복원하는 것을 시연했기 때문입니다.
우리 게임의 코드도 이렇게 쉽게 분석당할 수 있다니!
코드 난독화는 프로그램의 동작은 그대로 유지하면서 코드를 읽기 어렵게 만드는 것입니다. 마치 암호 해독 퍼즐처럼, 설령 코드를 얻어내더라도 그 의미를 파악하기 어렵게 만듭니다.
변수명을 무의미하게 바꾸고, 코드 구조를 복잡하게 만들어 리버스 엔지니어링을 방해합니다.
다음 코드를 살펴봅시다.
// 난독화 전 원본 코드
class PlayerScore {
int score = 0;
void addPoints(int points) {
score += points;
}
}
// 난독화 후 (빌드 시 자동 변환됨)
class a1 {
int b2 = 0;
void c3(int d4) {
b2 = (b2 ^ d4) + (d4 & 0xFF) - (~d4 & b2);
}
}
// pubspec.yaml에 난독화 설정
// flutter:
// obfuscate: true
// split-debug-info: ./debug-info/
// 빌드 명령어
// flutter build apk --obfuscate --split-debug-info=./debug-info
김개발 씨는 컨퍼런스에서 돌아온 후 밤잠을 설쳤습니다. 발표자가 인기 게임의 APK 파일을 디컴파일하는 것을 직접 봤기 때문입니다.
화면에 나타난 코드는 거의 원본과 다름없었습니다. 암호화 키, API 엔드포인트, 게임 로직까지 모든 것이 노출되어 있었습니다.
다음 날 출근해서 박시니어 씨에게 말했습니다. "우리 게임도 이렇게 뚫릴 수 있는 거예요?" 박시니어 씨가 고개를 끄덕였습니다.
"물론이죠. 하지만 난독화를 적용하면 훨씬 어렵게 만들 수 있어요." 그렇다면 코드 난독화란 정확히 무엇일까요?
쉽게 비유하자면, 난독화는 마치 외계어로 번역된 요리 레시피와 같습니다. 원래 "양파를 다진다"라고 적혀 있던 것이 "x1을 a7한다"로 바뀌는 것입니다.
요리 결과물은 똑같지만, 레시피만 봐서는 무슨 요리인지 알 수 없습니다. 코드도 마찬가지로, 실행 결과는 같지만 읽어서 이해하기는 매우 어려워집니다.
난독화가 없던 시절에는 어땠을까요? 디컴파일 도구를 사용하면 앱의 소스코드를 거의 원본에 가깝게 복원할 수 있었습니다.
해커들은 이렇게 얻은 코드를 분석해서 보안 취약점을 찾고, 치트 프로그램을 만들었습니다. 특히 Flutter 앱은 Dart 코드가 비교적 쉽게 복원되기 때문에 더 취약했습니다.
바로 이런 문제를 해결하기 위해 난독화 기술이 발전했습니다. 난독화는 여러 기법을 사용합니다.
식별자 난독화는 변수명, 함수명, 클래스명을 a, b, c 같은 무의미한 이름으로 바꿉니다. 제어 흐름 난독화는 코드의 실행 순서를 복잡하게 꼬아놓습니다.
문자열 암호화는 코드에 포함된 문자열을 암호화해서 저장합니다. 위의 코드를 살펴보겠습니다.
원본 코드에서는 PlayerScore, score, addPoints 같은 이름이 의미를 명확히 전달합니다. 하지만 난독화 후에는 a1, b2, c3 같은 이름으로 바뀝니다.
더 나아가 단순한 덧셈 연산이 비트 연산과 논리 연산이 뒤섞인 복잡한 식으로 변환됩니다. 결과는 같지만, 읽어서 이해하기는 훨씬 어려워집니다.
실제 현업에서는 어떻게 활용할까요? Flutter에서는 빌드 시 --obfuscate 플래그를 추가하면 자동으로 난독화가 적용됩니다.
--split-debug-info 옵션은 디버그 정보를 별도 파일로 분리해서, 크래시 리포트 분석에는 사용하되 앱에는 포함시키지 않습니다. 대부분의 프로덕션 앱은 이 설정을 기본으로 적용합니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수는 난독화를 과신하는 것입니다.
난독화는 리버스 엔지니어링을 어렵게 만들 뿐, 불가능하게 만들지는 않습니다. 충분한 시간과 기술력이 있는 해커는 결국 코드를 분석해낼 수 있습니다.
따라서 난독화는 다른 보안 기법과 함께 사용해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
난독화를 적용한 후, 김개발 씨는 직접 자신의 앱을 디컴파일해보았습니다. 화면에 나타난 것은 알아볼 수 없는 코드의 바다였습니다.
"이 정도면 쉽게는 못 뚫겠네요!" 김개발 씨가 안심했습니다.
실전 팁
💡 - Flutter 프로덕션 빌드에는 항상 --obfuscate 옵션을 사용하세요
- ProGuard(Android)나 유사 도구를 추가로 적용하면 더 강력해집니다
- 난독화만으로는 완벽하지 않으니 다른 보안 기법과 병행하세요
6. 무결성 체크
김개발 씨가 출시 후 모니터링을 하다가 이상한 현상을 발견했습니다. 동일한 버전 번호인데 해시값이 다른 앱 설치 요청이 들어오고 있었습니다.
누군가 앱을 변조한 후 다시 배포하고 있는 것 같았습니다.
무결성 체크는 앱이 원본 그대로인지 확인하는 것입니다. 마치 봉인된 편지의 밀랍 도장처럼, 누군가 중간에 내용을 바꿨다면 그 흔적이 남게 됩니다.
앱의 서명, 파일의 해시값, 실행 환경 등을 검사해서 변조나 조작이 있었는지 탐지합니다.
다음 코드를 살펴봅시다.
class IntegrityChecker {
// 앱 서명 검증 (Android)
Future<bool> verifyAppSignature() async {
final packageInfo = await PackageInfo.fromPlatform();
final signature = await _getAppSignature();
// 정품 앱의 서명과 비교
const validSignature = 'SHA256:AB:CD:EF:12:34:...';
return signature == validSignature;
}
// 핵심 파일 해시 검증
Future<bool> verifyFileIntegrity() async {
final coreFiles = ['libapp.so', 'assets/game_data.bin'];
for (final file in coreFiles) {
final hash = await _calculateFileHash(file);
final expectedHash = await _getExpectedHash(file);
if (hash != expectedHash) {
return false; // 파일이 변조됨
}
}
return true;
}
// 루팅/탈옥 환경 탐지
Future<bool> isSecureEnvironment() async {
if (await _isRooted()) return false;
if (await _isEmulator()) return false;
if (await _isDebuggerAttached()) return false;
return true;
}
}
김개발 씨는 로그를 분석하다가 수상한 패턴을 발견했습니다. 특정 지역에서 같은 IP 대역으로 대량의 계정이 생성되고 있었는데, 이 계정들이 사용하는 앱의 해시값이 정품과 달랐습니다.
누군가 앱을 변조한 버전을 퍼뜨리고 있었던 것입니다. 박시니어 씨에게 보고하자 심각한 표정을 지었습니다.
"변조된 앱이 돌아다니는 건 정말 위험해요. 무결성 체크를 강화해야겠네요." 그렇다면 무결성 체크란 정확히 무엇일까요?
쉽게 비유하자면, 무결성 체크는 마치 의약품의 밀봉 테이프와 같습니다. 약을 구매할 때 테이프가 뜯어져 있으면 누군가 손댔을 가능성이 있다고 판단하는 것처럼, 앱도 원본 상태 그대로인지 확인하는 장치가 필요합니다.
서명이 다르거나 파일이 변경되었다면 변조된 앱입니다. 무결성 체크가 없던 시절에는 어땠을까요?
해커들은 정품 앱을 다운받아 분석한 후, 보안 로직을 제거하거나 치트 기능을 추가해서 다시 패키징했습니다. 이렇게 만들어진 해적판 앱이 비공식 경로로 퍼져나갔습니다.
사용자들은 이것이 변조된 앱인지도 모르고 설치했고, 게임 생태계가 오염되었습니다. 바로 이런 문제를 해결하기 위해 무결성 체크가 도입되었습니다.
무결성 체크는 여러 단계로 이루어집니다. 첫째, 앱 서명 검증입니다.
안드로이드와 iOS는 앱에 개발자 서명을 포함하는데, 이 서명이 원본과 같은지 확인합니다. 둘째, 파일 해시 검증입니다.
핵심 파일들의 SHA256 해시값이 예상값과 일치하는지 확인합니다. 셋째, 실행 환경 검사입니다.
루팅된 기기나 에뮬레이터에서는 실행을 제한합니다. 위의 코드를 한 줄씩 살펴보겠습니다.
verifyAppSignature 메서드는 현재 실행 중인 앱의 서명을 가져와서 정품 서명과 비교합니다. verifyFileIntegrity는 게임의 핵심 파일들이 변조되지 않았는지 해시값으로 확인합니다.
isSecureEnvironment는 루팅, 에뮬레이터, 디버거 연결 여부를 검사해서 안전한 환경인지 판단합니다. 실제 현업에서는 어떻게 활용할까요?
대형 게임들은 앱 시작 시 무결성 체크를 수행하고, 문제가 발견되면 실행을 중단합니다. 일부 게임은 더 나아가 실행 중에도 주기적으로 검사를 수행합니다.
또한 서버와 통신할 때 클라이언트의 무결성 정보를 함께 전송해서, 변조된 클라이언트의 요청을 거부하기도 합니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수는 무결성 체크 로직 자체가 우회될 수 있다는 점을 간과하는 것입니다. 체크하는 코드 자체를 패치해버리면 소용없습니다.
따라서 검증 로직을 여러 곳에 분산시키고, 난독화와 함께 적용해야 합니다. 또한 정상 사용자를 차단하지 않도록 신중하게 구현해야 합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 무결성 체크를 강화한 후, 변조된 앱에서의 접속 시도가 모두 차단되었습니다.
해적판 앱 사용자들은 게임에 접속할 수 없게 되었고, 정품 앱을 다운받으라는 안내 메시지가 표시되었습니다. "이제 우리 앱만 진짜로 동작하네요!" 김개발 씨가 뿌듯해했습니다.
실전 팁
💡 - 무결성 체크 로직을 한 곳에 모으지 말고 여러 곳에 분산시키세요
- 서버에서도 클라이언트 무결성을 검증하면 더 안전합니다
- 루팅 탐지는 오탐 가능성이 있으니 사용자 경험을 해치지 않게 주의하세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
AAA급 게임 프로젝트 완벽 가이드
Flutter와 Flame 엔진을 활용하여 AAA급 퀄리티의 모바일 게임을 개발하는 전체 과정을 다룹니다. 기획부터 앱 스토어 출시까지, 실무에서 필요한 모든 단계를 이북처럼 술술 읽히는 스타일로 설명합니다.
빌드와 배포 자동화 완벽 가이드
Flutter 앱 개발에서 GitHub Actions를 활용한 CI/CD 파이프라인 구축부터 앱 스토어 자동 배포까지, 초급 개발자도 쉽게 따라할 수 있는 빌드 자동화의 모든 것을 다룹니다.
게임 분석과 메트릭스 완벽 가이드
Flutter와 Flame으로 개발한 게임의 성공을 측정하고 개선하는 방법을 배웁니다. Firebase Analytics 연동부터 A/B 테스팅, 리텐션 분석까지 데이터 기반 게임 운영의 모든 것을 다룹니다.
애니메이션 시스템 커스터마이징 완벽 가이드
Flutter와 Flame 게임 엔진에서 고급 애니메이션 시스템을 구현하는 방법을 다룹니다. 스켈레탈 애니메이션부터 절차적 애니메이션까지, 게임 개발에 필요한 핵심 애니메이션 기법을 실무 예제와 함께 배워봅니다.
Flutter Flame 게임 테스팅과 디버깅 완벽 가이드
Flutter와 Flame 엔진으로 개발한 게임의 품질을 보장하는 테스팅 기법과 디버깅 도구를 다룹니다. 단위 테스트부터 골든 테스트, 크래시 리포팅까지 실무에서 바로 적용할 수 있는 내용을 담았습니다.