플랫폼별 통합
Java · Spring (JSP)
국내 엔터프라이즈에서 가장 많은 Java · Spring 환경입니다. JSP든 Thymeleaf든 서버가 직전 저장본(canvasData)을 페이지에 내려주고, 저장은 REST 엔드포인트로 받는 구조가 표준입니다.
공통 전제
뷰어 산출물 /pdfv/를 정적 리소스로 서빙해야 합니다 —
Spring Boot라면 src/main/resources/static/pdfv/에 두거나,
앞단 Nginx·Apache에서 해당 경로를 서빙하면 됩니다. 시작하기 참고.
JSP
contract-view.jsp jsp
<%-- contract-view.jsp --%>
<%@ page contentType="text/html; charset=UTF-8" %>
<div id="pdf-container" style="width:100%; height:80vh"></div>
<script src="/pdfv/sdk/pdfv-sdk.js"></script>
<script>
// 컨트롤러에서 Jackson으로 직렬화해 둔 값 — JS 문자열 리터럴로 안전 주입
// model.addAttribute("canvasDataJson",
// objectMapper.writeValueAsString(latestCanvasData)); // null이면 "null"
var initialCanvasData = <%= canvasDataJson %>;
var viewer = Inko.mount('#pdf-container', {
src: '/pdfv/index.html',
pdfUrl: '/files/<%= contract.getFileName() %>',
fileName: '<%= contract.getFileName() %>',
initialCanvasData: initialCanvasData || undefined,
onSave: function (canvasData, ok) {
if (!ok) return;
fetch('/api/annotations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contractId: <%= contract.getId() %>,
canvasData: canvasData
})
});
}
});
</script> canvasData 인라인 주입은 JSON 직렬화를 거치세요
canvasData는 길이가 긴 불투명 문자열입니다. <%= %>로 따옴표 없이 그대로 찍지 말고,
예제처럼 컨트롤러에서 ObjectMapper.writeValueAsString()으로 JS 문자열 리터럴을 만들어 내려보내야
특수문자에 안전합니다. 값이 더 커지면 인라인 대신 /api/annotations/{docId}/latest를 fetch로 받아 주입하는 방식을 권장합니다.
Thymeleaf
Thymeleaf는 th:inline="javascript"가 값을 JS 리터럴로 자동 이스케이프하므로 가장 깔끔합니다.
contract-view.html html
<!-- contract-view.html (Thymeleaf) -->
<div id="pdf-container" style="width:100%; height:80vh"></div>
<script src="/pdfv/sdk/pdfv-sdk.js"></script>
<script th:inline="javascript">
// th:inline="javascript"가 값을 JS 리터럴로 안전하게 이스케이프합니다
var contractId = /*[[${contract.id}]]*/ 0;
var pdfUrl = /*[[${contract.fileUrl}]]*/ '';
var initialCanvasData = /*[[${latestCanvasData}]]*/ null;
var viewer = Inko.mount('#pdf-container', {
src: '/pdfv/index.html',
pdfUrl: pdfUrl,
initialCanvasData: initialCanvasData || undefined,
onSave: function (canvasData, ok) {
if (!ok) return;
fetch('/api/annotations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contractId: contractId, canvasData: canvasData })
});
}
});
</script>저장 엔드포인트 (Spring)
AnnotationController.java java
// AnnotationController.java — 저장(append-only INSERT) + 최신본 조회
@RestController
@RequestMapping("/api/annotations")
public class AnnotationController {
private final JdbcTemplate jdbcTemplate;
public AnnotationController(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public record SaveRequest(Long contractId, String canvasData) {}
@PostMapping
public Map<String, Object> save(@RequestBody SaveRequest req) {
jdbcTemplate.update(
"INSERT INTO doc_annotations (doc_id, canvas_data, version) " +
"VALUES (?, ?, COALESCE((SELECT MAX(version) FROM doc_annotations " +
"WHERE doc_id = ?), 0) + 1)",
req.contractId(), req.canvasData(), req.contractId());
return Map.of("ok", true);
}
@GetMapping("/{docId}/latest")
public Map<String, Object> latest(@PathVariable Long docId) {
List<String> rows = jdbcTemplate.queryForList(
"SELECT canvas_data FROM doc_annotations " +
"WHERE doc_id = ? ORDER BY version DESC LIMIT 1",
String.class, docId);
return Map.of("canvasData", rows.isEmpty() ? "" : rows.get(0));
}
}테이블 설계(append-only)는 저장과 버전 관리의 DDL 예시를 그대로 사용하면 됩니다.
주의사항
- Spring Security CSRF — CSRF(사이트 간 요청 위조)를 막기 위해 Spring Security는 POST 요청에 토큰을 요구합니다. 보호가 켜져 있으면
fetch헤더에 토큰을 추가하세요:headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=_csrf]').content }(메타 태그는<sec:csrfMetaTags/>또는 Thymeleafth:content="${_csrf.token}"로 출력). - 요청 크기 제한 — 편집량이 많으면 canvasData가 수 MB가 될 수 있습니다.
server.tomcat.max-http-form-post-size·앞단 Nginxclient_max_body_size를 함께 점검하세요. - DB 컬럼 — 길이 제한 없는
TEXT(Oracle은CLOB) 타입을 사용하세요.