백엔드 언어별 예제
Node.js, Java(Spring Boot), C#(ASP.NET Core), Python, PHP, Go에서 보고서 정의와 데이터를 JSON으로 내려 주고 출력 이력을 남기는 예제입니다. 어떤 백엔드 언어든 쓸 수 있습니다.
FreeReport는 화면 구성, 인쇄, 파일 저장을 브라우저에서 처리하므로 서버는 보고서 양식과 데이터를 JSON으로 돌려주기만 하면 됩니다. 어떤 언어·프레임워크든 쓸 수 있고 서버에 설치할 것이 없습니다. 아래 예제는 모두 같은 주소 네 개를 구현하므로, 백엔드를 무엇으로 고르든 브라우저 코드는 그대로입니다.
| 주소 | 응답 | 설명 |
|---|---|---|
GET /api/reports/{name} | JSON | 디자이너에서 저장한 보고서 양식(정의). |
PUT /api/reports/{name} | 204 | 양식 저장(디자이너 onSave). 이름과 본문이 JSON인지 확인합니다. |
GET /api/reports/sales/data?from=&to= | { rows: [...] } | 조회 조건에 맞는 행(뷰어 fetchData). 키는 양식에서 쓰는 필드 이름입니다. |
POST /api/report-logs | 204 | 인쇄·저장 한 건(뷰어 onOutput): kind, report, pages, rows, params. |
브라우저 (모든 백엔드 공통)
import { createViewer } from '@freereportjs/core';
import '@freereportjs/core/styles.css';
const json = (url: string, init?: RequestInit) =>
fetch(url, init).then(r => { if (!r.ok) throw new Error(r.status + ' ' + url); return r.json(); });
// 1. the report form (made in the designer) 2. rows for the parameters 3. output log
const viewer = createViewer(document.getElementById('viewer')!, {
definition: await json('/api/reports/sales'),
params: { from: '2026-10-01', to: '2026-10-31' },
fetchData: params => json('/api/reports/sales/data?' + new URLSearchParams(params as Record<string, string>)),
onOutput: e => fetch('/api/report-logs', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(e),
}).then(r => r.ok), // false: the server refused, nothing is printed or saved
lang: 'ko',
});
// designer: createDesigner(el, { onSave: def => fetch('/api/reports/sales', { method: 'PUT', body: JSON.stringify(def), headers: { 'content-type': 'application/json' } }) })서버 예제
// Node.js 20+ · npm i express@5 pg @freereportjs/core
import express from 'express';
import { readFile, writeFile } from 'node:fs/promises';
import pg from 'pg';
import { loadFonts, renderPdf } from '@freereportjs/core';
const app = express();
const db = new pg.Pool(); // PGHOST, PGUSER, … from the environment
app.use(express.json({ limit: '5mb' }));
const file = name => {
if (!/^[\w-]+$/.test(name)) throw Object.assign(new Error('bad name'), { status: 400 });
return 'reports/' + name + '.json';
};
const salesRows = async (from, to) => (await db.query(
`SELECT to_char(sold_on, 'YYYY-MM-DD') AS date, region, product, qty, amount::float8 AS amount
FROM sales WHERE sold_on BETWEEN $1 AND $2 ORDER BY region, sold_on`, [from, to])).rows;
// report form: the viewer loads it, the designer saves it
app.get('/api/reports/:name', async (req, res) => { res.type('json').send(await readFile(file(req.params.name), 'utf8')); });
app.put('/api/reports/:name', async (req, res) => {
await writeFile(file(req.params.name), JSON.stringify(req.body, null, 2));
res.sendStatus(204);
});
// data for the viewer's parameters
app.get('/api/reports/sales/data', async (req, res) => {
res.json({ rows: await salesRows(String(req.query.from), String(req.query.to)) });
});
// output log (viewer onOutput)
app.post('/api/report-logs', async (req, res) => {
const { kind, report, pages, rows, params } = req.body;
await db.query('INSERT INTO report_log (user_id, kind, report, pages, row_count, params, ip) VALUES ($1, $2, $3, $4, $5, $6, $7)',
[req.user?.id ?? null /* from your auth middleware */, kind, report, pages, rows, JSON.stringify(params), req.ip]);
res.sendStatus(204);
});
// only JavaScript runtimes can also make the PDF on the server (batch jobs, e-mail attachments)
const fonts = await loadFonts(['Nanum Gothic']);
app.get('/api/reports/sales/pdf', async (req, res) => {
const definition = JSON.parse(await readFile(file('sales'), 'utf8'));
const rows = await salesRows(String(req.query.from), String(req.query.to));
res.type('pdf').send(Buffer.from(await renderPdf(definition, { rows }, { fonts, params: req.query })));
});
app.listen(3000);// Spring Boot 3 (spring-boot-starter-web, spring-boot-starter-jdbc) · Java 17+
@RestController
@RequestMapping("/api")
public class ReportController {
private static final Path DIR = Path.of("reports");
private final JdbcTemplate jdbc;
private final ObjectMapper json;
public ReportController(JdbcTemplate jdbc, ObjectMapper json) { this.jdbc = jdbc; this.json = json; }
private static Path file(String name) {
if (!name.matches("[\\w-]+")) throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
return DIR.resolve(name + ".json");
}
// report form: the viewer loads it, the designer saves it
@GetMapping(value = "/reports/{name}", produces = MediaType.APPLICATION_JSON_VALUE)
public String definition(@PathVariable String name) throws IOException {
return Files.readString(file(name));
}
@PutMapping(value = "/reports/{name}", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> save(@PathVariable String name, @RequestBody String body) throws IOException {
json.readTree(body); // reject anything that is not JSON
Files.writeString(file(name), body);
return ResponseEntity.noContent().build();
}
// data for the viewer's parameters (quoted aliases keep lower-case keys on every database)
@GetMapping("/reports/sales/data")
public Map<String, Object> sales(@RequestParam LocalDate from, @RequestParam LocalDate to) {
return Map.of("rows", jdbc.queryForList("""
SELECT sold_on AS "date", region AS "region", product AS "product", qty AS "qty", amount AS "amount"
FROM sales WHERE sold_on BETWEEN ? AND ? ORDER BY region, sold_on""", from, to));
}
// output log (viewer onOutput)
@PostMapping("/report-logs")
public ResponseEntity<Void> log(@RequestBody Map<String, Object> e, Principal user, HttpServletRequest req) throws JsonProcessingException {
jdbc.update("INSERT INTO report_log (user_id, kind, report, pages, row_count, params, ip) VALUES (?, ?, ?, ?, ?, ?, ?)",
user == null ? null : user.getName(), e.get("kind"), e.get("report"), e.get("pages"), e.get("rows"),
json.writeValueAsString(e.get("params")), req.getRemoteAddr());
return ResponseEntity.noContent().build();
}
}// ASP.NET Core 8+ minimal API · dotnet add package Dapper Microsoft.Data.SqlClient
using System.Text.Json;
using System.Text.RegularExpressions;
using Dapper;
using Microsoft.Data.SqlClient;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var cs = builder.Configuration.GetConnectionString("Default");
string ReportFile(string name) => Regex.IsMatch(name, @"^[\w-]+$")
? Path.Combine("reports", name + ".json")
: throw new BadHttpRequestException("bad name");
// report form: the viewer loads it, the designer saves it
app.MapGet("/api/reports/{name}", (string name) =>
Results.Text(File.ReadAllText(ReportFile(name)), "application/json"));
app.MapPut("/api/reports/{name}", async (string name, HttpRequest req) =>
{
using var doc = await JsonDocument.ParseAsync(req.Body); // reject anything that is not JSON
await File.WriteAllTextAsync(ReportFile(name), doc.RootElement.GetRawText());
return Results.NoContent();
});
// data for the viewer's parameters
app.MapGet("/api/reports/sales/data", async (DateOnly from, DateOnly to) =>
{
await using var db = new SqlConnection(cs);
var rows = await db.QueryAsync(
"""
SELECT CONVERT(char(10), sold_on, 23) AS [date], region, product, qty, amount
FROM sales WHERE sold_on BETWEEN @from AND @to ORDER BY region, sold_on
""",
new { from = from.ToDateTime(TimeOnly.MinValue), to = to.ToDateTime(TimeOnly.MinValue) });
return Results.Ok(new { rows });
});
// output log (viewer onOutput)
app.MapPost("/api/report-logs", async (JsonElement e, HttpContext ctx) =>
{
await using var db = new SqlConnection(cs);
await db.ExecuteAsync(
"INSERT INTO report_log (user_id, kind, report, pages, row_count, params, ip) VALUES (@user, @kind, @report, @pages, @rows, @params, @ip)",
new
{
user = ctx.User.Identity?.Name,
kind = e.GetProperty("kind").GetString(),
report = e.GetProperty("report").GetString(),
pages = e.GetProperty("pages").GetInt32(),
rows = e.GetProperty("rows").GetInt32(),
@params = e.GetProperty("params").GetRawText(),
ip = ctx.Connection.RemoteIpAddress?.ToString(),
});
return Results.NoContent();
});
app.Run();# Python 3.10+ · pip install fastapi uvicorn "psycopg[binary]"
import json, re
from datetime import date
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query, Request, Response
import psycopg
from psycopg.rows import dict_row
app = FastAPI()
DIR = Path("reports")
DSN = "dbname=erp user=report"
def report_file(name: str) -> Path:
if not re.fullmatch(r"[\w-]+", name):
raise HTTPException(400, "bad name")
return DIR / f"{name}.json"
# report form: the viewer loads it, the designer saves it
@app.get("/api/reports/{name}")
def definition(name: str):
return Response(report_file(name).read_text("utf-8"), media_type="application/json")
@app.put("/api/reports/{name}", status_code=204)
async def save(name: str, request: Request):
body = await request.json() # rejects anything that is not JSON
report_file(name).write_text(json.dumps(body, ensure_ascii=False, indent=2), "utf-8")
# data for the viewer's parameters (dates become "2026-10-01", numeric becomes a number)
@app.get("/api/reports/sales/data")
def sales(from_: date = Query(alias="from"), to: date = Query()):
with psycopg.connect(DSN, row_factory=dict_row) as db:
rows = db.execute(
"SELECT sold_on AS date, region, product, qty, amount FROM sales"
" WHERE sold_on BETWEEN %s AND %s ORDER BY region, sold_on", (from_, to)).fetchall()
return {"rows": rows}
# output log (viewer onOutput)
@app.post("/api/report-logs", status_code=204)
async def log(request: Request):
e = await request.json()
with psycopg.connect(DSN) as db:
db.execute(
"INSERT INTO report_log (user_id, kind, report, pages, row_count, params, ip)"
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
(None, # your signed-in user
e["kind"], e["report"], e["pages"], e["rows"],
json.dumps(e["params"], ensure_ascii=False), request.client.host))
# run: uvicorn main:app<?php
// PHP 8+ · send /api/* to this file
// Apache (.htaccess in /api): FallbackResource /api/index.php
// nginx: location /api/ { try_files $uri /api/index.php?$query_string; }
$pdo = new PDO('mysql:host=localhost;dbname=erp;charset=utf8mb4', 'report', getenv('DB_PASSWORD'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$path = trim(substr(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), strlen('/api/')), '/');
$method = $_SERVER['REQUEST_METHOD'];
header('Content-Type: application/json; charset=utf-8');
// report form: the viewer loads it, the designer saves it
if (preg_match('#^reports/([\w-]+)$#', $path, $m)) {
$file = __DIR__ . '/reports/' . $m[1] . '.json';
if ($method === 'GET') { readfile($file); exit; }
if ($method === 'PUT') {
$body = file_get_contents('php://input');
json_decode($body, flags: JSON_THROW_ON_ERROR); // reject anything that is not JSON
file_put_contents($file, $body);
http_response_code(204); exit;
}
}
// data for the viewer's parameters (JSON_NUMERIC_CHECK: MySQL decimals arrive as strings)
if ($path === 'reports/sales/data' && $method === 'GET') {
$q = $pdo->prepare('SELECT sold_on AS date, region, product, qty, amount FROM sales
WHERE sold_on BETWEEN ? AND ? ORDER BY region, sold_on');
$q->execute([$_GET['from'], $_GET['to']]);
echo json_encode(['rows' => $q->fetchAll()], JSON_UNESCAPED_UNICODE | JSON_NUMERIC_CHECK); exit;
}
// output log (viewer onOutput)
if ($path === 'report-logs' && $method === 'POST') {
$e = json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR);
$pdo->prepare('INSERT INTO report_log (user_id, kind, report, pages, row_count, params, ip) VALUES (?, ?, ?, ?, ?, ?, ?)')
->execute([$_SESSION['user_id'] ?? null, $e['kind'], $e['report'], $e['pages'], $e['rows'],
json_encode($e['params'], JSON_UNESCAPED_UNICODE), $_SERVER['REMOTE_ADDR']]);
http_response_code(204); exit;
}
http_response_code(404);// Go 1.22+ (method and {name} patterns in net/http) · go get github.com/jackc/pgx/v5
package main
import (
"database/sql"
"encoding/json"
"io"
"net/http"
"os"
"regexp"
_ "github.com/jackc/pgx/v5/stdlib"
)
var validName = regexp.MustCompile(`^[\w-]+$`)
type sale struct {
Date string `json:"date"`
Region string `json:"region"`
Product string `json:"product"`
Qty int `json:"qty"`
Amount float64 `json:"amount"`
}
func main() {
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
panic(err)
}
// report form: the viewer loads it, the designer saves it
http.HandleFunc("GET /api/reports/{name}", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !validName.MatchString(name) {
http.Error(w, "bad name", http.StatusBadRequest)
return
}
http.ServeFile(w, r, "reports/"+name+".json")
})
http.HandleFunc("PUT /api/reports/{name}", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
body, err := io.ReadAll(io.LimitReader(r.Body, 5<<20))
if !validName.MatchString(name) || err != nil || !json.Valid(body) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if err := os.WriteFile("reports/"+name+".json", body, 0o644); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
})
// data for the viewer's parameters
http.HandleFunc("GET /api/reports/sales/data", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
rows, err := db.QueryContext(r.Context(),
`SELECT to_char(sold_on, 'YYYY-MM-DD'), region, product, qty, amount::float8
FROM sales WHERE sold_on BETWEEN $1 AND $2 ORDER BY region, sold_on`, q.Get("from"), q.Get("to"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
out := []sale{}
for rows.Next() {
var s sale
if err := rows.Scan(&s.Date, &s.Region, &s.Product, &s.Qty, &s.Amount); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
out = append(out, s)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"rows": out})
})
// output log (viewer onOutput)
http.HandleFunc("POST /api/report-logs", func(w http.ResponseWriter, r *http.Request) {
var e struct {
Kind string `json:"kind"`
Report string `json:"report"`
Pages int `json:"pages"`
Rows int `json:"rows"`
Params json.RawMessage `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
_, err := db.ExecContext(r.Context(),
`INSERT INTO report_log (kind, report, pages, row_count, params, ip) VALUES ($1, $2, $3, $4, $5, $6)`,
e.Kind, e.Report, e.Pages, e.Rows, string(e.Params), r.RemoteAddr)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
})
http.ListenAndServe(":8080", nil)
}-- output log table (PostgreSQL; MySQL: BIGINT AUTO_INCREMENT, SQL Server: BIGINT IDENTITY, DATETIME2)
CREATE TABLE report_log (
id BIGSERIAL PRIMARY KEY,
logged_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
user_id VARCHAR(64),
kind VARCHAR(16) NOT NULL, -- print, pdf, xlsx, xlsx-data, csv, hwpx
report VARCHAR(100) NOT NULL,
pages INT,
row_count INT,
params TEXT, -- the parameters as JSON
ip VARCHAR(45)
);
CREATE INDEX report_log_time ON report_log (logged_at);확인할 점
- 날짜는 "yyyy-MM-dd" 문자열로, 금액은 숫자로 돌려주세요. 그래야 서식과 합계가 맞습니다. 행의 키는 양식에서 쓰는 필드 이름입니다.
- 보고서 이름은 파일 경로에 쓰기 전에 검사하고, 저장하는 양식이 JSON인지 확인하세요. 저장과 이력 주소는 로그인한 사용자만 쓰게 하세요.
- 서버가 여러 대라면 양식을 파일 대신 DB 테이블에 두세요. 정의는 JSON 텍스트 컬럼 하나입니다.
- 서버에서 PDF·엑셀 파일을 만들려면 자바스크립트 런타임(Node.js, Deno, Bun의 renderPdf, renderXlsx)이 필요합니다. Java, C#, Python, PHP, Go에서는 작은 Node 서비스를 호출하거나 브라우저에서 파일을 만들게 하세요. 서버에서 생성
- Spring + JSP(전자정부 표준프레임워크)는 내려받을 수 있는 예제 프로젝트를 보세요. 프레임워크와 전자정부