신분증 마스킹에서 좌표는 맞는데 엉뚱한 곳이 가려졌다
신분증에서 주민번호 뒷자리를 가리는 기능을 만들었는데, 일부 이미지에서 엉뚱한 영역이 칠해졌습니다. OCR 이 준 좌표는 정확했고 문제는 EXIF 회전 정보였습니다. 마스킹과 회전 보정의 순서를 뒤집어서 해결했고, 그 과정에서 배운 것을 정리합니다.
입사하고 세 달쯤 됐을 때 받은 일이었습니다. 셀러가 계약할 때 신분증과 사업자등록증을 올리는데, 그 서류에서 필요한 값을 뽑아 쓰고 주민등록번호 뒷자리는 가려서 저장해야 했습니다.
당시 저는 이걸 단순한 작업으로 봤습니다. OCR 이 텍스트 위치를 좌표로 돌려주니까 그 위에 검정 사각형을 덮으면 끝이라고요. 절반은 맞았고 절반은 틀렸습니다.
OCR 이 준 좌표에 다각형을 채우는 것까지는 쉬웠다
OCR 응답에는 인식한 각 필드의 영역이 꼭짓점 좌표 목록으로 들어옵니다. 사각형이 아니라 다각형인 이유는 사진이 비스듬하게 찍히면 텍스트 영역도 기울어지기 때문입니다.
그래서 Graphics2D 로 다각형을 채우는 유틸을 만들었습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static byte[] applyPolygonMaskToImage(byte[] imageBytes, List<List<Point>> polygons, String format) {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(imageBytes));
Graphics2D graphic = image.createGraphics();
graphic.setColor(Color.BLACK);
for (List<Point> poly : polygons) {
Polygon polygon = new Polygon();
for (Point p : poly) {
polygon.addPoint((int) p.getX(), (int) p.getY());
}
graphic.fillPolygon(polygon);
}
graphic.dispose();
return convertBufferedImageToByteArray(image, format);
}
Java 21, Spring Boot 3.2.x 환경입니다. 로컬에서 테스트할 때는 잘 됐습니다. 제가 캡처해서 만든 샘플 이미지로만 봤으니까요.
휴대폰으로 찍은 사진에서 다른 곳이 칠해졌다
QA 에서 실제 휴대폰으로 찍은 신분증을 올리니 마스킹이 엉뚱한 데 들어갔습니다. 어떤 건 아래쪽 여백이 칠해지고, 어떤 건 이름 위에 칠해졌습니다.
처음엔 OCR 이 좌표를 잘못 준 줄 알았습니다. 응답을 찍어보니 좌표는 정확했습니다. 그러면 제 다각형 그리는 코드가 문제인가 싶었는데 그것도 아니었죠. 같은 좌표로 같은 코드가 어떤 이미지에서는 맞고 어떤 이미지에서는 틀렸습니다.
한참 헤매다 이미지 자체를 뜯어봤습니다. 원인은 EXIF Orientation 이었습니다.
휴대폰으로 세로로 찍은 사진은 픽셀 데이터가 가로로 저장되고, “이건 90도 돌려서 봐야 한다”는 정보를 EXIF 메타데이터에 따로 넣습니다. 이미지 뷰어나 브라우저는 그 값을 읽어서 알아서 회전해 보여줍니다.
그런데 여기서 두 가지가 어긋납니다.
- OCR 서버는 회전을 적용한 상태, 즉 사람이 보는 방향으로 인식해서 좌표를 돌려줍니다.
ImageIO.read()는 EXIF 를 무시하고 저장된 픽셀 그대로 읽습니다.
그러니까 OCR 은 “회전 후 기준”의 좌표를 주는데 저는 “회전 전 픽셀”에 그걸 그리고 있었던 겁니다. 90도 어긋난 좌표계에 칠하고 있었으니 엉뚱한 곳이 가려지는 게 당연했습니다.
순서를 뒤집었다
해결은 간단했습니다. 마스킹하기 전에 EXIF 회전을 먼저 적용해서 OCR 이 본 것과 같은 좌표계로 맞추는 것입니다.
1
2
3
4
5
6
public static byte[] applyPolygonMaskToImage(byte[] imageBytes, List<List<Point>> polygons, String format) {
// 마스킹 전에 EXIF 회전을 반영해 OCR 이 본 좌표계와 일치시킨다
BufferedImage image = correctImageOrientation(imageBytes);
Graphics2D graphic = image.createGraphics();
// ... 이하 동일
}
correctImageOrientation() 은 metadata-extractor 로 EXIF 의 Orientation 태그를 읽고 그 값에 맞게 이미지를 회전시켜 BufferedImage 로 돌려줍니다. 회전 정보가 없으면 원본을 그대로 반환합니다.
여기서 부수적으로 하나 더 걸렸습니다. 마스킹한 결과를 다시 업로드해야 하는데, 기존 업로드 경로가 MultipartFile 을 받도록 되어 있었거든요. MultipartFile 은 요청에 실려 오는 파일을 표현하는 인터페이스라 메모리에서 만든 바이트 배열을 담을 구현체가 없습니다.
그래서 MultipartFile 을 직접 구현했습니다.
1
2
3
4
5
6
7
public class CustomMultipartFile implements MultipartFile {
private final String name;
private final String originalFilename;
private final String contentType;
private final byte[] content;
// getter 와 getInputStream 등 인터페이스 메서드 구현
}
업로드 경로를 바이트 배열도 받도록 바꾸는 방법도 있었습니다. 그런데 그 경로를 쓰는 곳이 여러 군데였고, 저는 입사 3개월이라 각 호출부의 사정을 다 알지 못했습니다. 그래서 호출부를 건드리지 않는 쪽을 골랐습니다. 지금 다시 판단한다면 인터페이스를 바꾸는 쪽을 봤을 것 같은데, 그때는 이게 안전한 선택이었습니다.
남은 것과 배운 것
이후로도 이미지 처리는 계속 손봤습니다. 변환 로직을 정리하고, PDF 를 이미지로 바꿀 때 DPI 가 낮아 OCR 인식률이 떨어지는 문제를 조정하고, OCR 실패 로그를 남겼습니다. 한 번에 끝나는 종류의 일이 아니었습니다.
가장 크게 배운 건 이미지는 보이는 것과 저장된 것이 다르다는 사실입니다. 브라우저가 알아서 돌려주니까 개발하면서는 차이를 못 느끼는데, 좌표를 다루는 순간 그 차이가 그대로 버그가 됩니다.
그리고 이게 개인정보라서 더 아찔했습니다. 마스킹이 빗나가면 기능이 안 되는 게 아니라 가려야 할 주민번호가 그대로 남습니다. 실패했을 때 조용히 통과하는 종류의 버그였던 거죠. 그래서 이후에는 마스킹 결과를 눈으로 확인하는 절차를 QA 에 넣었습니다.
한계도 남았습니다. EXIF 회전은 처리했지만 심하게 기울어진 사진은 여전히 OCR 인식률이 낮습니다. 기울기를 보정하는 전처리까지 넣을지는 그때 판단하지 않았고, 아직 그대로입니다.