프로필 이미지를 업로드하면 바로 노출되지 않고 운영팀이 수동으로 심사해야 했다.
유해 이미지는 극히 일부인데, 99%의 정상 이미지까지 전부 수동 확인을 거쳐야 하니 리소스 낭비가 심했다.
1. 문제 상황
기존 흐름은 다음과 같았다.
문제점
운영팀이 모든 업로드를 일일이 확인해야 하니 심사 자체가 병목이었다.
심사가 끝날 때까지 유저에게는 이미지가 보이지 않아, 대기 시간만큼 경험도 나빠졌다.
실제 유해 이미지 비율은 1% 미만이었다.
고작 1%를 잡아내기 위해 99%의 정상 이미지를 사람이 일일이 심사하고 있었던 셈이다.
2. AWS Rekognition 도입
AWS Rekognition의 DetectModerationLabels API를 선택했다.
이미지를 분석해서 성인물, 폭력, 혐오 등 유해 컨텐츠를 자동 탐지해준다.
API 응답 구조
{
"ModerationLabels": [
{
"Name": "Explicit Nudity",
"Confidence": 98.5,
"ParentName": "",
"TaxonomyLevel": 1
},
{
"Name": "Graphic Male Nudity",
"Confidence": 95.2,
"ParentName": "Explicit Nudity",
"TaxonomyLevel": 2
}
],
"ModerationModelVersion": "7.0"
}탐지 가능한 카테고리:
| 카테고리 | 설명 |
|---|---|
| Explicit Nudity | 노출 |
| Suggestive | 선정적 |
| Violence | 폭력 |
| Visually Disturbing | 혐오 이미지 |
| Drugs | 마약 |
| Hate Symbols | 나치 등 혐오 상징 |
동기 vs 비동기
두 가지 선택지가 있었다:
동기 처리를 선택했다.
Rekognition 응답이 1-2초로 빠르고 대부분이 정상 이미지라, 업로드 직후 이미지를 노출할 수 있었다.
Kafka나 Worker를 새로 구축할 필요 없이 기존 업로드 코드만 수정하면 된다는 점도 이유였다.
3. 구현
새로운 플로우
RekognitionModerationService 구현
public record ImageModerationResult(
bool IsSafe,
List<string> DetectedLabels,
double MaxConfidence
);
[Singleton]
public class RekognitionModerationService(
AmazonRekognitionClient client,
ILogger<RekognitionModerationService> logger)
: IRekognitionModerationService
{
// 75% 이상 confidence면 유해로 판단
private const float ConfidenceThreshold = 75f;
// API 호출 시 최소 50% 이상만 반환받음
private const float MinConfidence = 50f;
public async Task<ImageModerationResult> ModerateImageAsync(byte[] imageBytes)
{
try
{
var request = new DetectModerationLabelsRequest
{
Image = new Image
{
Bytes = new MemoryStream(imageBytes)
},
MinConfidence = MinConfidence
};
var response = await client.DetectModerationLabelsAsync(request);
// 75% 이상 confidence인 유해 레이블만 필터링
var dangerousLabels = response.ModerationLabels
.Where(l => l.Confidence >= ConfidenceThreshold)
.ToList();
var isSafe = !dangerousLabels.Any();
if (!isSafe)
{
logger.LogWarning(
"유해 컨텐츠 감지: {Labels}, MaxConfidence: {MaxConfidence}%",
string.Join(", ", dangerousLabels.Select(l => l.Name)),
dangerousLabels.Max(l => l.Confidence));
}
return new ImageModerationResult(
IsSafe: isSafe,
DetectedLabels: dangerousLabels.Select(l => $"{l.Name} ({l.Confidence:F1}%)").ToList(),
MaxConfidence: dangerousLabels.Any() ? dangerousLabels.Max(l => l.Confidence) : 0
);
}
catch (Exception ex)
{
logger.LogError(ex, "Rekognition 이미지 분석 중 오류 발생");
// Rekognition 오류 시 안전하게 HOLD 처리
return new ImageModerationResult(
IsSafe: false,
DetectedLabels: new List<string> { $"Rekognition Error: {ex.Message}" },
MaxConfidence: 0
);
}
}
}Rekognition API 호출 실패 시 IsSafe: false를 반환해서 기존처럼 HOLD 상태로 처리한다.
장애 시에도 유해 이미지가 바로 노출되는 일은 없다.
UserProfileUpload 수정
기존 업로드 로직에 Rekognition 호출을 추가했다.
public class UserProfileUpload(
IRepository<RdbUser> userRepository,
IAmazonS3Handler amazonS3Handler,
IRekognitionModerationService rekognitionModerationService,
ILogger<UserProfileUpload> logger)
: IUserProfileUpload
{
public async Task<ResultCodes> UploadProfileImage(...)
{
// ... 이미지 처리 로직 ...
using (var memoryStream = new MemoryStream())
{
await image.WriteAsync(memoryStream);
// Rekognition 이미지 검증
var imageBytes = memoryStream.ToArray();
var moderationResult = await rekognitionModerationService.ModerateImageAsync(imageBytes);
if (moderationResult.IsSafe)
{
// 자동 승인
userProfileImages.ProfileImageExamineStatus = ProfileImageExamineStatus.APPROVE;
userProfileImages.IsApproved = true;
userProfileImages.Manager = "Auto-Rekognition";
logger.LogInformation(
"프로필 이미지 자동 승인: UserId={UserId}, Position={Position}",
user.Id, position);
}
else
{
// 수동 심사 필요
userProfileImages.ProfileImageExamineStatus = ProfileImageExamineStatus.HOLD;
userProfileImages.IsApproved = false;
userProfileImages.DisApproveOrRemoveReason =
$"Rekognition 감지: {string.Join(", ", moderationResult.DetectedLabels)}";
}
// S3 업로드
memoryStream.Position = 0;
await amazonS3Handler.UploadFileToS3Async(BucketType.Public, memoryStream, fileName);
}
await userRepository.SaveChangesAsync();
return ResultCodes.Success;
}
}DI 등록
// AWS Rekognition 이미지 검증 서비스
serviceCollection.AddSingleton<AmazonRekognitionClient>();
serviceCollection.AddSingleton<IRekognitionModerationService, RekognitionModerationService>();AWS IAM 권한
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"rekognition:DetectModerationLabels"
],
"Resource": "*"
}
]
}4. Threshold 설정
threshold는 카테고리별로 나누지 않고 75% 단일 값으로 운영 중이다.
카테고리마다 민감도를 다르게 가져가는 방법도 있지만, 게임 특성상 캐주얼한 이미지가 많아서 기준을 너무 엄격하게 잡으면 수영복 수준의 이미지까지 오탐으로 걸린다.
단일 threshold로 시작하고, 오탐 사례가 쌓여 기준이 명확해지면 그때 카테고리별 세분화를 검토하기로 했다.
5. 비용
| 월간 업로드 | 비용 (USD) | 비용 (KRW) |
|---|---|---|
| 1,000 | $1 | ₩1,300 |
| 10,000 | $10 | ₩13,000 |
| 50,000 | $50 | ₩65,000 |
| 100,000 | $100 | ₩130,000 |
과거에는 계정 생성 후 12개월간 매월 5,000장까지 무료였지만, 2025년 7월 이후 생성된 신규 계정에는 12개월 무료 티어 대신 크레딧 방식의 프리 플랜이 적용된다.
도입 전에 자기 계정 기준 과금 조건을 요금 페이지에서 확인하는 편이 안전하다.
6. 결과
| 항목 | Before | After |
|---|---|---|
| 정상 이미지 노출 | 수동 심사 후 (수 시간~수 일) | 즉시 |
| 운영팀 심사량 | 전체 업로드 | 1% 미만 (유해 의심만) |
| 업로드 응답 시간 | 추가 지연 없음 | Rekognition 동기 호출로 1-2초 추가 |
| 유저 불만 | 이미지 안 보여요! | 거의 없음 |
자동 승인률 99%는 추정치가 아니라 운영툴에서 HOLD로 남는 건수를 집계해 확인한 값이다.
전체 업로드 중 HOLD 비율이 1% 미만이었고, 나머지는 사람 손을 거치지 않고 노출된다.
7. 운영 포인트
모니터링 지표
| 지표 | 설명 | 알람 조건 |
|---|---|---|
| 자동 승인률 | APPROVE / 전체 업로드 | < 90% 시 알람 |
| 수동 심사 대기 | HOLD 상태 이미지 수 | > 100건 시 알람 |
| Rekognition 실패율 | API 오류 / 전체 호출 | > 1% 시 알람 |
운영툴 개선
HOLD 상태 이미지 조회 시 Rekognition이 감지한 이유가 표시되도록 했다:
Rekognition 감지: Explicit Nudity (98.5%), Graphic Male Nudity (95.2%)운영팀이 왜 HOLD됐는지 바로 알 수 있어서 심사 속도가 빨라졌다.
8. 한계
Rekognition의 사전 학습 모델은 범용적이라서, 우리 게임 특유의 "유해 기준"을 완벽히 맞추진 못한다.
예를 들어 특정 게임 내 아이템이 무기로 오인되거나, 회사 정책상 금지해야 하는 특정 로고나 심볼은 잡아내지 못한다.
이를 해결하는 수단이 Amazon Rekognition Custom Labels다.
우리 데이터셋으로 모델을 파인튜닝하는 기능인데, 당장 도입하지는 않았다.
학습용 데이터를 모으고 라벨링하는 공수가 크고, 현재 99% 자동화만으로도 충분한 ROI가 나오고 있어 우선순위가 낮다.
ML 관련 업무를 전담할 동료가 합류하거나 운영 리소스에 여유가 생기면 그때 진행하기로 했다.
현재 자동화 수준으로 운영이 가능해 우선순위를 낮췄다.
마무리
대부분이 정상인 데이터를 전수 심사하는 대신, 의심되는 것만 골라서 심사하는 구조로 바꿨다.
Rekognition 도입으로 99%의 정상 유저는 즉시 이미지가 노출되고, 운영팀은 진짜 유해한 1%에만 집중할 수 있게 됐다.