참조 구현 데모 · 실제 서비스가 아닙니다 · 입력한 내용은 이 브라우저에만 저장되고 서버로 전송되지 않습니다
사회적가치·ESG 통합진단

개발자가 없어도 판단하실 수 있게, 화면으로 적었습니다

공고에 “비개발자가 이해할 수 있는 수준으로 적어 주시면 도움이 됩니다”라고 쓰셨습니다. 이 페이지는 그 요청에 대한 답입니다. 전문 용어를 쓸 때는 그 자리에서 한 줄로 풉니다. 로그인 없이 열리므로 내부에서 그대로 공유하셔도 됩니다.

공고 필수사항

무엇으로 만들었고, 왜 그것을 골랐나

이미 열어 두신 Supabase와 Netlify를 그대로 씁니다. 새로 배우실 도구를 늘리지 않는 것이 첫 번째 기준이었습니다.

지금 있는 앱은 버리지 않습니다

AI 도구로 만드신 HTML·JavaScript 앱의 문항과 점수 계산은 이미 동작하는 자산입니다. 저희가 하는 일은 그것을 다시 쓰는 것이 아니라 담을 그릇을 만드는 것입니다 — 계정, 저장, 이력, 배포. 문항과 산식은 옮겨 담고, 이후에는 파일 한 곳(지표 정의 파일)만 고치면 문항이 바뀌게 둡니다. 이 데모도 같은 구조로 만들어 두었습니다.

공고 필수사항

어떤 상태가 되면 「끝」인가

공고에서 물으신 항목입니다. 아래 10개가 전부 확인되면 완료로 보고 인수인계에 들어갑니다. 「돌아간다」가 아니라 「이 화면에서 이렇게 확인된다」로 적었습니다.

완료 조건어디서 확인하나
1

누구나 스스로 계정을 만들고 이메일 인증까지 마칠 수 있다

담당자가 대신 만들어 주지 않아도 파일럿 업체가 직접 가입합니다. 인증하지 않은 계정은 진단 화면에 들어가지 못합니다.

이 데모의 회원가입 → 데모 메일함이 데모에서 확인 가능
2

로그인한 계정은 자기 진단만 보고, 남의 진단은 보지 못한다

계정을 바꿔 로그인하면 이전 계정의 진단이 하나도 보이지 않습니다. 본 개발에서는 이 보장을 데이터베이스 RLS 정책이 맡습니다.

대시보드 하단 「계정별 저장 격리」 패널이 데모에서 확인 가능
3

20문항 진단을 끝까지 입력하고 제출할 수 있다

빠뜨린 문항이 있으면 다음으로 넘어가지 않고 어느 문항인지 짚어 줍니다. 입력 중 자동 임시저장되어 창을 닫았다 열어도 이어서 씁니다.

진단 입력 화면이 데모에서 확인 가능
4

제출하면 총점·등급·영역 점수·개선 제안이 담긴 리포트가 나온다

점수 계산 규칙은 코드 한 곳에 모여 있고 자동 테스트로 지켜집니다. 개선 제안은 지표 정의에서 규칙으로 뽑으며 생성형 AI를 쓰지 않습니다.

리포트 화면이 데모에서 확인 가능
5

리포트를 인쇄하거나 PDF로 저장할 수 있다

인쇄물에는 메뉴와 버튼이 나오지 않고, 표가 페이지 경계에서 잘리지 않습니다. 별도 프로그램 없이 브라우저 인쇄로 됩니다.

리포트 화면의 「인쇄 · PDF로 저장」이 데모에서 확인 가능
6

재진단하면 이전 회차와 문항 단위로 비교된다

공고에 쓰신 마지막 문장이 이 항목입니다. 총점 변화, 등급 변화, 어느 문항이 왜 올랐는지까지 한 화면에서 확인됩니다.

재진단 델타 트랙이 데모에서 확인 가능
7

발주사 명의 Supabase 프로젝트에 스키마와 RLS 정책이 올라가 있다

아래 SQL을 발주사 계정에서 실행하고, 계정 A로 B의 데이터를 조회해 0행이 나오는 것까지 함께 확인합니다.

이 페이지의 스키마 · RLS SQL본 개발에서 완성
8

발주사 명의 Netlify에 배포되어 있고, 코드를 고치면 자동으로 반영된다

저장소 소유자는 발주사입니다. 저희는 초대받은 협업자로 작업하고, 계약 종료 시 권한을 반납합니다.

이 페이지의 배포 파이프라인본 개발에서 완성
9

키가 브라우저로 새어 나가지 않는다

공개해도 되는 키와 서버에만 두어야 하는 키를 환경변수로 나눠 두고, 배포 결과물에 서버 키가 들어가지 않았는지 확인합니다.

이 페이지의 환경변수 분리본 개발에서 완성
10

개발자가 없어도 운영할 수 있는 인수인계 문서가 있다

문항을 바꾸는 법, 사용자 문의가 왔을 때 확인할 곳, 장애 시 되돌리는 법을 화면 캡처와 함께 남깁니다. 이 문서가 없으면 완료가 아닙니다.

본 개발 산출물본 개발에서 완성
공고 필수사항

확장 범위에 대한 의견 — 지금 · 다음 · 나중

공고에 「아직 정하지 않은 범위」라고 쓰신 부분입니다. 저희 의견은 「지금은 넣지 말고, 나중에 붙일 자리만 미리 열어 두자」입니다.

풀스택 확장을 이번에 포함하는 게 나을까요 — 저희 판단

이번에는 빼는 편이 낫다고 봅니다. 관리자 화면과 기관용 집계는 “파일럿 업체가 몇 곳이고, 공공기관이 무엇을 보고 싶어 하는가”가 정해져야 제대로 만들 수 있는데, 그 답은 파일럿을 한 바퀴 돌려 봐야 나옵니다. 지금 추측으로 만들면 다시 만들게 됩니다.

대신 나중에 붙일 수 있는 상태로 끝내는 것은 이번 범위에 넣었습니다. 위 스키마에서 진단은 이미 기관별로 나뉘어 있고 회차 번호와 지표 세트 버전을 갖고 있어서, 관리자 화면은 읽기 전용 조회 화면을 추가하는 일이지 데이터를 옮기는 일이 아닙니다. 그때 예상 규모는 화면 2~3개, 별도 건으로 논의드리면 됩니다.

공고 필수사항

소스와 자료를 어떻게 다루나

특허 출원을 준비 중이고 NDA를 예정하고 계신다고 하셨습니다. 지킬 수 있는 것만 적었습니다.

소스 접근 범위

저장소는 발주사 명의(GitHub 또는 발주사가 지정한 곳)로 만들고, 저희는 초대받은 협업자로만 들어갑니다. 저희 계정에 사본을 두지 않습니다.

작업 종료 시 처리

납품 후 저장소·Supabase·Netlify 권한을 반납하고, 작업 PC의 로컬 사본을 삭제합니다. 삭제 사실을 서면으로 남깁니다.

키와 계정

Supabase·Netlify 계정은 발주사 소유이고, 저희는 초대로 접근합니다. API 키는 저장소에 넣지 않고 호스팅의 환경변수에만 둡니다. 서버 전용 키는 브라우저로 나가는 이름(NEXT_PUBLIC_)을 붙이지 않습니다.

특허 출원 준비 중인 자료

지표 체계·산식 등 출원 범위에 들어가는 자료는 별도 저장소나 문서로 분리해 받는 것을 제안드립니다. 계약 전 NDA 체결에 동의하며, 출원 범위와 공개 시점은 발주사 판단에 따릅니다.

제3자 도구

이 프로젝트의 코드·지표 자료를 외부 AI 서비스에 업로드하지 않습니다. 이 데모의 개선 제안도 생성형 AI가 아니라 지표 정의에서 규칙으로 뽑은 것입니다.

약속하지 않는 것

보안 인증 취득(ISMS 등), 침투 테스트, 24시간 장애 대응은 이 규모의 계약에 포함하지 않습니다. 필요하시면 별도로 논의해야 합니다.

로그인 정보를 위조하면 어떻게 되는지 — 지금 실행한 결과

검사 중

이 데모의 세션은 서버가 서명한 쿠키입니다. 실서비스에서는 같은 역할을 Supabase Auth 의 토큰이 하고, 여기에 더해 데이터베이스의 RLS 정책이 한 겹 더 막습니다 — 로그인 검사를 통과해도 남의 행은 조회 결과에 나오지 않습니다.

공고 필수사항

Supabase — 표 3개와 접근 규칙

아래 SQL은 발주사 Supabase 프로젝트의 SQL Editor에 그대로 붙여 넣어 실행하는 것을 전제로 썼습니다. 이 데모는 발주사 계정에 접근할 권한이 없어 실제로 연결하지는 않았습니다.

organizations

기관 하나 = 계정 하나. Supabase 로그인 사용자와 1:1로 붙습니다.

assessments

진단 회차. 1차·2차·3차… 총점과 등급을 함께 저장해 지표가 바뀌어도 과거 점수가 보존됩니다.

assessment_answers

문항 응답. 회차마다 20행. 점수와 근거 메모가 들어갑니다.

표 만들기 (CREATE TABLE)

Supabase → SQL Editor → New query 에 붙여 넣고 Run 하면 됩니다.

-- ─────────────────────────────────────────────────────────────
-- 1) 기관(계정) — Supabase Auth 의 사용자와 1:1로 붙는다
-- ─────────────────────────────────────────────────────────────
create table public.organizations (
  id            uuid primary key references auth.users(id) on delete cascade,
  name          text        not null,                 -- 기관·기업명
  contact_email text        not null,
  business_no   text,                                 -- 사업자등록번호(선택)
  created_at    timestamptz not null default now(),
  updated_at    timestamptz not null default now()
);

-- ─────────────────────────────────────────────────────────────
-- 2) 진단 회차 — 1차 · 2차 · 3차 …
-- ─────────────────────────────────────────────────────────────
create table public.assessments (
  id            uuid        primary key default gen_random_uuid(),
  org_id        uuid        not null references public.organizations(id) on delete cascade,
  round         integer     not null check (round >= 1),
  status        text        not null default 'draft'
                            check (status in ('draft','submitted')),
  -- 지표 세트 버전. 지표를 개정해도 과거 회차의 점수 근거가 보존된다
  indicator_set text        not null default 'v1',
  total_score   numeric(5,1),                         -- 100점 환산
  grade         text,                                 -- 관찰 / 기초 / 보통 / 우수 / 최우수
  submitted_at  timestamptz,
  created_at    timestamptz not null default now(),
  unique (org_id, round)                              -- 같은 기관에 같은 회차는 하나뿐
);

create index assessments_org_round_idx
  on public.assessments (org_id, round desc);

-- ─────────────────────────────────────────────────────────────
-- 3) 문항 응답 — 회차당 20행
-- ─────────────────────────────────────────────────────────────
create table public.assessment_answers (
  id              uuid      primary key default gen_random_uuid(),
  assessment_id   uuid      not null references public.assessments(id) on delete cascade,
  indicator_code  text      not null,                 -- 'E1' · 'S3' · 'SV5' …
  score           smallint  not null check (score between 0 and 5),
  note            text,                               -- 근거 메모
  created_at      timestamptz not null default now(),
  unique (assessment_id, indicator_code)              -- 한 문항에 답은 하나
);

create index assessment_answers_assessment_idx
  on public.assessment_answers (assessment_id);

아래 SQL을 한 문장으로 옮기면

“로그인한 계정 본인의 진단만 조회됩니다.” A 기관이 B 기관의 진단 주소를 알아내 직접 입력해도, 데이터베이스가 그 행을 아예 돌려주지 않습니다. 화면에서 숨기는 것이 아니라 데이터가 나오지 않는 것이라, 앱 코드에 실수가 있어도 새지 않습니다.

접근 규칙 (Row Level Security)

-- ─────────────────────────────────────────────────────────────
-- RLS(Row Level Security) — 「행 단위 접근 제어」
-- 켜는 순간, 정책에 적힌 조건을 만족하는 행만 오갈 수 있다.
-- 애플리케이션 코드에 버그가 있어도 데이터베이스가 마지막으로 막는다.
-- ─────────────────────────────────────────────────────────────
alter table public.organizations      enable row level security;
alter table public.assessments        enable row level security;
alter table public.assessment_answers enable row level security;

-- 기관: 로그인한 본인의 행만
create policy "org_select_own" on public.organizations
  for select using (auth.uid() = id);

create policy "org_update_own" on public.organizations
  for update using (auth.uid() = id) with check (auth.uid() = id);

create policy "org_insert_self" on public.organizations
  for insert with check (auth.uid() = id);

-- 진단: 내 기관의 진단만 조회·생성·수정
create policy "assessment_select_own" on public.assessments
  for select using (auth.uid() = org_id);

create policy "assessment_insert_own" on public.assessments
  for insert with check (auth.uid() = org_id);

create policy "assessment_update_own" on public.assessments
  for update using (auth.uid() = org_id) with check (auth.uid() = org_id);

-- 응답: 부모 진단이 내 것일 때만. exists 로 소유권을 거슬러 올라가 확인한다
create policy "answer_select_own" on public.assessment_answers
  for select using (
    exists (
      select 1 from public.assessments a
      where a.id = assessment_answers.assessment_id
        and a.org_id = auth.uid()
    )
  );

create policy "answer_write_own" on public.assessment_answers
  for all using (
    exists (
      select 1 from public.assessments a
      where a.id = assessment_answers.assessment_id
        and a.org_id = auth.uid()
    )
  ) with check (
    exists (
      select 1 from public.assessments a
      where a.id = assessment_answers.assessment_id
        and a.org_id = auth.uid()
    )
  );

-- 제출이 끝난 회차는 고쳐 쓰지 못하게 (이력의 신뢰를 지킨다)
create policy "assessment_no_edit_after_submit" on public.assessments
  as restrictive for update
  using (status = 'draft');

가입과 동시에 기관 만들기 (트리거)

「가입은 됐는데 기관 정보가 없는」 어중간한 상태가 생기지 않게 합니다.

-- 회원가입과 동시에 기관 행을 만든다.
-- 앱 코드가 두 번 호출할 필요가 없어, 「가입은 됐는데 기관이 없는」 상태가 생기지 않는다.
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = public
as $$
begin
  insert into public.organizations (id, name, contact_email)
  values (
    new.id,
    coalesce(new.raw_user_meta_data->>'org_name', '(기관명 미입력)'),
    new.email
  );
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute function public.handle_new_user();

정책이 진짜 막는지 확인하는 절차

말로 「막힙니다」 하지 않고, 함께 화면을 보며 확인합니다. 5분이면 됩니다.

-- 정책이 실제로 막는지 확인하는 방법 (Supabase SQL Editor 에서 5분)
-- 계정 A 로 로그인한 상태를 흉내 내고, 계정 B 의 진단을 조회해 본다.

-- 1) A 의 시점으로 전환
select set_config('request.jwt.claims',
  json_build_object('sub', '<계정 A 의 uuid>')::text, true);
set local role authenticated;

-- 2) 전체 진단을 조회 — A 의 것만 나온다
select org_id, round, total_score from public.assessments;

-- 3) B 의 진단 id 를 콕 집어 조회 — 0행이 나온다 (에러가 아니라 「없음」)
select * from public.assessments where id = '<계정 B 의 진단 id>';

-- 4) B 의 진단에 답을 밀어 넣어 본다 — with check 위반으로 거부된다
insert into public.assessment_answers (assessment_id, indicator_code, score)
values ('<계정 B 의 진단 id>', 'E1', 5);
-- ERROR: new row violates row-level security policy
공고 필수사항

배포 — 코드를 고치면 자동으로 올라가는 상태

사람이 파일을 올리지 않습니다. 저장소에 올리면 Netlify가 알아서 빌드하고 배포합니다.

  1. 1

    저장소에 올림

    발주사 명의 저장소의 main 브랜치에 코드가 올라갑니다.

  2. 2

    Netlify가 감지

    저장소와 연결해 두면 변경을 자동으로 알아챕니다.

  3. 3

    빌드

    환경변수를 주입해 배포용 결과물을 만듭니다. 실패하면 배포하지 않고 알려 줍니다.

  4. 4

    배포 · 도메인 연결

    성공한 결과물만 실제 주소에 걸립니다. 도메인은 발주사 소유로 연결합니다.

  5. 5

    문제 시 되돌리기

    이전 배포 목록에서 클릭 한 번으로 직전 버전으로 돌아갑니다.

환경변수 분리 — 키가 새지 않게

이름 앞에 NEXT_PUBLIC_ 이 붙은 값만 브라우저로 나갑니다. 규칙 하나로 사고를 막습니다.

# Netlify → Site configuration → Environment variables
# 브라우저로 나가도 되는 값 (공개 전제로 설계된 키)
NEXT_PUBLIC_SUPABASE_URL=https://<프로젝트>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon key>

# [서버 전용] 아래 값은 브라우저로 나가면 안 된다 — NEXT_PUBLIC_ 을 붙이지 않는다.
#    붙이는 순간 빌드 결과물에 박혀 브라우저로 나간다.
SUPABASE_SERVICE_ROLE_KEY=<service role key>   # RLS 를 무시하는 키. 관리 작업 전용

키 노출을 막는 세 가지 습관

  • 저장소에 키를 넣지 않습니다. 키는 Netlify 환경변수에만 둡니다. 예시 파일(.env.example)에는 이름만 적고 값은 비웁니다.
  • 서버 전용 키에 NEXT_PUBLIC_ 을 붙이지 않습니다. 붙이는 순간 빌드 결과물에 박혀 누구나 볼 수 있게 됩니다.
  • 배포 결과물에서 서버 키를 검색해 확인합니다. 납품 전 점검 항목에 넣고, 확인한 화면을 인수인계 문서에 남깁니다.

이 데모만은 Netlify가 아니라 Vercel에 올려 두었습니다

발주사가 지정하신 호스팅은 Netlify이고, 납품은 발주사 명의 Netlify로 합니다. 다만 이 제안용 데모는 저희 계정으로 즉시 올려 보여드리려고 Vercel을 썼습니다. 코드는 Next.js 표준이라 Netlify에서도 그대로 동작합니다(Netlify는 Next.js를 공식 지원합니다). 위 5단계는 Netlify 기준으로 적었고, 배포처가 바뀌어도 순서는 같습니다.

이 데모가 실제로 한 것과 하지 않은 것

항목이 데모본 개발에서는
회원가입 · 이메일 인증 · 로그인동작함Supabase Auth로 교체. 인증 메일이 실제로 발송됩니다.
인증 전 계정의 진단 화면 접근 차단동작함동일. 서버에서 막는 구조를 그대로 유지합니다.
계정별 데이터 격리동작함키 접두어 → 데이터베이스 RLS 정책으로 교체. 보장이 더 강해집니다.
진단 입력 · 검증 · 자동 임시저장동작함동일. 임시저장 위치만 브라우저 → 데이터베이스로 옮깁니다.
리포트 · 인쇄 · PDF 저장동작함동일. 리포트 서식은 발주사 요구에 맞춰 조정합니다.
재진단 이력 비교동작함동일. 회차 수가 늘어도 두 회차를 골라 비교하는 구조입니다.
실제 Supabase 프로젝트 연결안 함발주사 계정에서 위 SQL을 실행하고 앱을 연결합니다.
실제 이메일 발송안 함Supabase Auth가 발송합니다. 발신자 주소는 발주사 도메인으로 설정합니다.
발주사 명의 Netlify 배포안 함발주사 계정에 저장소를 연결해 자동 배포를 켭니다.
지표 문항예시 세트발주사의 실제 지표·배점·등급 기준으로 교체합니다.

데모용 예시 지표 세트 — 화면 흐름을 보여주기 위해 임의로 구성한 20문항입니다. 공인 지표 체계가 아니며, 본 개발 시 발주사의 실제 지표로 교체됩니다(교체 지점은 data/indicators.ts 한 파일).

직접 가입해서 확인해 보기데모 첫 화면으로