플랫폼별 통합

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}/latestfetch로 받아 주입하는 방식을 권장합니다.

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/> 또는 Thymeleaf th:content="${_csrf.token}"로 출력).
  • 요청 크기 제한 — 편집량이 많으면 canvasData가 수 MB가 될 수 있습니다. server.tomcat.max-http-form-post-size·앞단 Nginx client_max_body_size를 함께 점검하세요.
  • DB 컬럼 — 길이 제한 없는 TEXT(Oracle은 CLOB) 타입을 사용하세요.