Skip to content
151 changes: 151 additions & 0 deletions docs/kangcheolung/issue-96-ocr-response-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# 이슈 #96 — OCR 응답 원본 저장 + 파서 회귀 테스트 기반

> 브랜치: `feature/96` · 관련 이슈: [PIUDAProject/Backend#96](https://github.com/PIUDAProject/Backend/issues/96)
>
> OCR 파싱 개선 1단계의 선행 작업. 후속: #B(표 처방전 좌표 파싱), #C(약봉투 다중 약)

---

## 1. 배경

약봉투·처방전 OCR 추출이 여러 서식에서 깨지는데, **실패를 재현할 수단이 없었다.**

| 문제 | 상세 |
|---|---|
| 응답 원본 유실 | `NaverOcrClient`가 응답을 `NaverOcrApiResponse`로 파싱한 뒤 원본을 버림. `OcrResult`엔 텍스트를 이어붙인 `raw_text`만 남고 좌표(`boundingPoly`)는 사라짐 |
| 테스트 0개 | `OcrParser` 단위 테스트가 없어, 서식 하나를 고치면 다른 서식이 회귀했는지 알 수 없음 |
| 개인정보 | `raw_text`에 환자 주민번호가 마스킹 없이 저장됨 |

이 이슈는 파싱 로직을 고치지 않는다. **회귀 테스트 기반**만 만든다. 응답 DTO(`OcrResultResponse`)
외부 계약은 그대로 두어 프론트 영향이 없다.

---

## 2. 변경

### 2-1. Naver 응답 원본 저장

`NaverOcrClient.callOcr()` — `bodyToMono(String.class)`로 원문을 받아 주입한 `ObjectMapper`로 파싱.
반환 타입을 `List<Field>` → `NaverOcrCallResult(fields, rawResponseJson)`로 변경.

```text
webClient.post()...bodyToMono(String.class) ← 원문 문자열
→ objectMapper.readValue(raw, NaverOcrApiResponse.class)
→ new NaverOcrCallResult(extractFields(response), raw)
```

- WebClient 코덱 한도를 256KB(기본) → 10MB로 상향. 표 처방전은 필드(텍스트+좌표 4점)가 수백 개라
기본 한도를 넘을 수 있음. `webClient.mutate().codecs(...)`로 이 클라이언트만 조정.
- `extractFields()` 검증(`inferResult != SUCCESS` → `OCR_API_ERROR`)은 그대로.
- 응답 원문을 재직렬화가 아니라 **문자열 그대로** 저장하는 이유: Naver의 `inferConfidence` 등
우리 DTO에 없는 필드까지 보존해 회귀 코퍼스의 충실도를 유지.

`OcrResult` — `raw_response` `MEDIUMTEXT` 컬럼 + 빌더 파라미터. `ddl-auto: update`라 마이그레이션 파일 불필요.
MySQL `TEXT`는 64KB라, 좌표가 붙는 표 처방전 응답(WebClient 한도 10MB)을 담으려면 `MEDIUMTEXT`(16MB)가 필요하다.

### 2-2. 주민번호 마스킹

신규 `global/util/PiiMasker` — 주민번호만 대상.

```java
// 6자리 [-] 7자리, 뒷자리 첫 숫자 1~8. 하이픈 선택적, 앞뒤 숫자 경계로 부분 일치 방지
Pattern.compile("(?<!\\d)\\d{6}\\s*-?\\s*[1-8]\\d{6}(?!\\d)") → "******-*******"
```

- 하이픈은 OCR이 놓치는 경우가 있어 선택적(`-?`). `(?<!\d)`/`(?!\d)`로 더 긴 숫자열 내부 부분 마스킹을 막는다.
- 형식이 고정이라 정규식으로 안전하게 잡힌다. 교부번호(`20260701-00042`, 뒤 5자리)는 패턴 불일치라 유지.
- 이름·생년월일은 형식이 없어 자동 식별이 어렵고, 저장 허용 범위(팀 합의)라 건드리지 않는다.
- `OcrCommandService`에서 `raw_text`·`raw_response` 둘 다 저장 직전에 통과.

### 2-3. 파서 회귀 테스트 하네스

| 파일 | 역할 |
|---|---|
| `test/resources/ocr/fixtures/*.json` | 저장된 Naver 응답(`NaverOcrApiResponse` 형태) |
| `test/resources/ocr/expected/manifest.json` | fixture → 기대 약 목록(`ParsedOcrData`) + `guard` 플래그 |
| `OcrFixtureLoader` | fixture/manifest 로드 |
| `OcrParserRegressionTest` | fixture별 `parse()` → precision/recall 리포트 + `guard` 단언 |

- `new OcrParser()` — 의존성이 없어 Mockito 불필요
- `guard=true`: 현재 정상 동작 → 결과가 어긋나면 **실패** (회귀 가드)
- `guard=false`: 아직 미달 → precision/recall만 **리포트**, 실패시키지 않음. 해당 이슈에서 fix + guard 승격

**fixture 2건 (초기)**

| 파일 | 출처 | 좌표 | 용도 |
|---|---|---|---|
| `pharmacy_receipt_starred.json` | 약제비 영수증 (별표형 약 이름, 개인정보 익명화) | 없음 | 현재 정상 — 회귀 가드 |
| `table_prescription_synth.json` | 합성 표 처방전. 헤더 `처방 의약품의`+`명칭` 분리를 의도적으로 재현 | 있음 | 이슈 B 대상 |

실제 약봉투·처방전 fixture는 팀이 사진에서 뽑아 이름 마스킹 후 추가한다.

---

## 3. 측정 (baseline)

```
[pharmacy_receipt_starred.json] exact P=1.00 R=1.00 | name R=1.00 | 기대 4, 추출 4, 정확일치 4
↳ 약제비 영수증 (별표형, 좌표 없음) - 현재 정상 동작, 회귀 가드
[table_prescription_synth.json] exact P=0.00 R=0.00 | name R=0.20 | 기대 5, 추출 1, 정확일치 0
↳ 합성 표 처방전 (좌표 포함) - 이슈 B 대상, 현재 미달
```

- `exact` = 4개 필드(이름/1회량/1일횟수/총일수) 완전 일치, `name` = 이름만 일치
- 표 처방전이 현재 얼마나 안 되는지가 수치로 고정됨 → **이슈 B가 R=1.00으로 뒤집는 게 목표**
- `OcrParserRegressionTest` 3 tests, 0 failures / `PiiMaskerTest` 통과

```bash
./gradlew test --tests "*OcrParserRegressionTest" --tests "*PiiMaskerTest"
```

---

## 4. 변경 파일

### 신규

| 파일 | 역할 |
|---|---|
| `domain/ocrresult/dto/NaverOcrCallResult` | OCR 호출 결과 — 파싱용 `fields` + 저장용 응답 원문 |
| `global/util/PiiMasker` | 주민번호 마스킹 |
| `test/.../ocrresult/fixture/OcrFixtureLoader` | fixture/manifest 로드 |
| `test/.../ocrresult/service/OcrParserRegressionTest` | 회귀 리포트 + 가드 |
| `test/.../global/util/PiiMaskerTest` | 마스킹 단위 테스트 |
| `test/resources/ocr/**` | fixture 2건 + manifest |

### 수정

| 파일 | 변경 |
|---|---|
| `NaverOcrClient` | 응답 원문 수신·파싱·반환, 코덱 한도 상향, 명시적 생성자 |
| `OcrResult` | `raw_response` 컬럼 + 빌더 |
| `OcrCommandService` | `NaverOcrCallResult` 반영, 저장 전 주민번호 마스킹 |

파싱 로직(`OcrParser`)·응답 DTO·보안 설정은 건드리지 않음.

---

## 5. 수동 검증 (로컬)

> 현재 `OcrController`·`MedicationController`에 로컬 테스트용 `userId` 하드코딩이 있음.
> **PR 전 `git checkout --`로 제거** (커밋 금지).

```bash
./gradlew bootRun --args='--spring.profiles.active=local'
```

`POST /api/ocr` (form-data: `seniorId=1`, `image=<처방전 사진>`, `ocrType=PRESCRIPTION`)

- [ ] `ocr_result` 새 행의 `raw_response`에 좌표 포함 응답 JSON 저장
- [ ] 주민번호 있는 처방전 → `raw_text`·`raw_response` 모두 `******-*******`
- [ ] `OcrResultResponse` 필드 형태 변화 없음

---

## 6. 후속

- **이슈 B**: 표 처방전 좌표 파싱 복구 — 게이트 정규식 `\s*`→`[ \t]*`, `parseByCoordinates`
헤더 x좌표 앵커 배정, `table_prescription_synth.json` guard 승격
- **이슈 C**: 약봉투 압축형(`약이름\n1정씩1회5일분` 반복) 다중 약
- **이슈 D**(범위 밖): ES/DrugInfo로 약 이름 검증
- fixture 코퍼스 확장: 실제 약봉투·처방전 사진 (이름 마스킹)
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
package com.piuda.callcare.domain.ocrresult.client;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.piuda.callcare.domain.ocrresult.dto.NaverOcrCallResult;
import com.piuda.callcare.domain.ocrresult.dto.response.NaverOcrApiResponse;
import com.piuda.callcare.global.exception.CallCareException;
import com.piuda.callcare.global.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import org.springframework.web.multipart.MultipartFile;

import java.time.Duration;
import java.util.List;
Expand All @@ -21,18 +22,35 @@

@Slf4j
@Component
@RequiredArgsConstructor
public class NaverOcrClient {

// 표 처방전은 필드(텍스트+좌표)가 많아 응답이 기본 코덱 한도(256KB)를 넘을 수 있어 상향
private static final int MAX_IN_MEMORY_SIZE = 10 * 1024 * 1024;

private final WebClient webClient;
private final ObjectMapper objectMapper;

@Value("${naver.ocr.invoke-url}")
private String invokeUrl;

@Value("${naver.ocr.secret-key}")
private String secretKey;

public List<NaverOcrApiResponse.Field> callOcr(MultipartFile image) {
public NaverOcrClient(WebClient webClient, ObjectMapper objectMapper) {
this.webClient = webClient.mutate()
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(MAX_IN_MEMORY_SIZE))
.build();
this.objectMapper = objectMapper;
}

/**
* 이미지를 Naver OCR에 보내 인식 결과를 받는다.
*
* @param image 처방전·약봉투 이미지
* @return 파싱용 {@code fields}와 저장용 응답 원문 JSON
* @throws com.piuda.callcare.global.exception.CallCareException OCR API 오류·실패({@code OCR_API_ERROR})
*/
public NaverOcrCallResult callOcr(MultipartFile image) {
try {
String filename = Objects.requireNonNullElse(image.getOriginalFilename(), "image.jpg");
String format = extractFormat(filename);
Expand All @@ -45,17 +63,18 @@ public String getFilename() {
}
};

NaverOcrApiResponse response = webClient.post()
String rawResponseJson = webClient.post()
.uri(invokeUrl)
.header("X-OCR-SECRET", secretKey)
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(BodyInserters.fromMultipartData("message", messageJson)
.with("file", imageResource))
.retrieve()
.bodyToMono(NaverOcrApiResponse.class)
.bodyToMono(String.class)
.block(Duration.ofSeconds(35));

return extractFields(response);
NaverOcrApiResponse response = objectMapper.readValue(rawResponseJson, NaverOcrApiResponse.class);
return new NaverOcrCallResult(extractFields(response), rawResponseJson);

} catch (WebClientResponseException e) {
log.error("Naver OCR API 응답 오류 - status: {}, body: {}", e.getStatusCode(), e.getResponseBodyAsString());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.piuda.callcare.domain.ocrresult.dto;

import com.piuda.callcare.domain.ocrresult.dto.response.NaverOcrApiResponse;

import java.util.List;

/**
* Naver OCR 호출 결과. 파싱에 쓰는 {@code fields}와, 실패 재현·회귀 테스트용으로
* 저장할 응답 원문 {@code rawResponseJson}을 함께 담는다.
*/
public record NaverOcrCallResult(
List<NaverOcrApiResponse.Field> fields,
String rawResponseJson
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ public class OcrResult {
@Column(name = "raw_text", columnDefinition = "TEXT")
private String rawText; // OCR로 추출된 원본 텍스트

// MEDIUMTEXT(16MB): 표 처방전 응답은 토큰마다 좌표가 붙어 TEXT(64KB)를 넘길 수 있음
@Column(name = "raw_response", columnDefinition = "MEDIUMTEXT")
private String rawResponse; // Naver OCR 응답 원문 JSON (좌표 포함, 실패 재현·회귀 테스트용)

@Column(name = "parsed_drug_name")
private String parsedDrugName; // OCR 결과에서 추출된 약 이름

Expand All @@ -55,11 +59,12 @@ public class OcrResult {
private LocalDateTime createdAt;

@Builder
public OcrResult(Senior senior, String imageUrl, OcrType ocrType, String rawText) {
public OcrResult(Senior senior, String imageUrl, OcrType ocrType, String rawText, String rawResponse) {
this.senior = senior;
this.imageUrl = imageUrl;
this.ocrType = ocrType;
this.rawText = rawText;
this.rawResponse = rawResponse;
this.isProcessed = false;
this.createdAt = LocalDateTime.now();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

import com.piuda.callcare.domain.ocrresult.client.NaverOcrClient;
import com.piuda.callcare.domain.ocrresult.converter.OcrResultConverter;
import com.piuda.callcare.domain.ocrresult.dto.NaverOcrCallResult;
import com.piuda.callcare.domain.ocrresult.dto.OcrParseResult;
import com.piuda.callcare.domain.ocrresult.dto.ParsedOcrData;
import com.piuda.callcare.domain.ocrresult.dto.response.NaverOcrApiResponse;
import com.piuda.callcare.domain.ocrresult.dto.response.OcrResultResponse;
import com.piuda.callcare.domain.ocrresult.entity.OcrResult;
import com.piuda.callcare.domain.ocrresult.enums.OcrType;
Expand All @@ -14,13 +14,12 @@
import com.piuda.callcare.domain.senior.repository.SeniorRepository;
import com.piuda.callcare.global.exception.CallCareException;
import com.piuda.callcare.global.exception.ErrorCode;
import com.piuda.callcare.global.util.PiiMasker;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
Expand All @@ -36,17 +35,18 @@ public OcrResultResponse processOcr(Long userId, Long seniorId, MultipartFile im
Senior senior = seniorRepository.findByIdAndUser_Id(seniorId, userId)
.orElseThrow(() -> new CallCareException(ErrorCode.SENIOR_NOT_FOUND));

// OCR 호출 → fields(텍스트 + 좌표 블록 목록) 반환
List<NaverOcrApiResponse.Field> fields = naverOcrClient.callOcr(image);
// OCR 호출 → fields(텍스트 + 좌표 블록 목록) + 응답 원문 반환
NaverOcrCallResult ocrCallResult = naverOcrClient.callOcr(image);

// 파싱: rawText 조립 + 약 정보 추출 (표 처방전이면 여러 약)
OcrParseResult parseResult = ocrParser.parse(fields, ocrType);
OcrParseResult parseResult = ocrParser.parse(ocrCallResult.fields(), ocrType);

// OcrResult DB 저장: rawText + 첫 번째 약 파싱 결과
// OcrResult DB 저장: rawText + 응답 원문 + 첫 번째 약 파싱 결과. 주민번호는 저장 전 마스킹
OcrResult ocrResult = OcrResult.builder()
.senior(senior)
.ocrType(ocrType)
.rawText(parseResult.rawText())
.rawText(PiiMasker.maskResidentNumber(parseResult.rawText()))
.rawResponse(PiiMasker.maskResidentNumber(ocrCallResult.rawResponseJson()))
.build();

ParsedOcrData first = parseResult.parsedDrugs().isEmpty()
Expand Down
34 changes: 34 additions & 0 deletions src/main/java/com/piuda/callcare/global/util/PiiMasker.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.piuda.callcare.global.util;

import java.util.regex.Pattern;

/**
* OCR 결과를 저장하기 전 민감정보를 가리는 유틸.
* <p>
* 주민등록번호만 대상으로 한다. 형식이 고정("6자리 [-] 7자리", 뒷자리 첫 숫자 1~8)이라
* 정규식으로 안전하게 잡힌다. 하이픈은 OCR이 놓치는 경우가 있어 선택적으로 두고,
* 앞뒤 숫자 경계(lookbehind/lookahead)로 더 긴 숫자열 내부 부분 일치를 막는다.
* 환자 이름·생년월일은 형식이 없어 자동 식별이 어렵고, 저장 허용 범위라 건드리지 않는다.
*/
public final class PiiMasker {

private static final Pattern RESIDENT_NUMBER =
Pattern.compile("(?<!\\d)\\d{6}\\s*-?\\s*[1-8]\\d{6}(?!\\d)");
private static final String MASK = "******-*******";

private PiiMasker() {
}

/**
* 텍스트 내 주민등록번호를 {@code ******-*******} 로 치환한다.
*
* @param text 원본 텍스트 (null 허용)
* @return 마스킹된 텍스트. {@code text}가 null이면 null
*/
public static String maskResidentNumber(String text) {
if (text == null) {
return null;
}
return RESIDENT_NUMBER.matcher(text).replaceAll(MASK);
}
}
Loading