브라우저에서 실행하고, 결과로 채점하는 SQL 학습 서비스 ‘NULL지마’ 제작기
대학교에서 SQL을 처음 배울 때, 저는 SELECT 문보다 설치 화면을 먼저 오래 봤습니다. Oracle이나 MySQL을 설치하고 계정을 만들고, 실습에 쓸 테이블을 정의한 다음, 조회할 데이터를 넣어야 비로소 첫 쿼리를 실행할 수 있었습니다. 설치가 한 번에 끝나지 않는 날에는 수업 시간보다 환경 설정에 더 많은 시간을 쓰기도 했습니다.
더 답답했던 점은 문법을 외운 뒤에도 연습할 데이터가 없으면 할 수 있는 일이 많지 않다는 것이었습니다. SELECT는 한 테이블만 있어도 시작할 수 있지만, JOIN은 관계를 가진 두 개 이상의 테이블이 필요합니다. GROUP BY를 연습하려면 묶었을 때 의미가 생기는 데이터가 충분히 있어야 합니다. SQL을 배우려고 했는데, SQL을 쓰기 전 준비가 먼저 장벽이 된 셈입니다.
실무에 들어가 보니 상황은 달랐습니다. 이미 운영 중인 데이터베이스와 테이블이 있었고, 개발자는 그 구조를 읽은 뒤 필요한 결과를 만드는 쿼리를 작성했습니다. 그때 대학 시절의 불편함이 더 선명해졌습니다. 입문자에게 필요한 것은 빈 실행창이 아니라, 바로 조회하고 관계를 따라가 볼 수 있는 데이터와 실패해도 다시 시작할 수 있는 환경이 아닐까 생각했습니다.
그래서 DB 설치와 데이터 준비 없이 웹사이트에 접속하자마자 SQL을 실행할 수 있는 서비스를 만들기 시작했습니다. 이름은 ‘NULL지마’입니다. 단순히 쿼리가 실행되는 화면을 만드는 데서 멈추지 않고, 학습용 데이터셋과 문제, 자동 채점, 스키마 탐색까지 한 흐름으로 연결하는 것이 목표였습니다.

처음에는 “웹에서 SQL을 연습할 수 있게 하자” 정도로 생각했습니다. 하지만 구현을 시작하면서 요구사항을 더 구체적인 조건으로 바꿨습니다. 첫째, 사용자가 별도의 DBMS를 설치하지 않아야 했습니다. 둘째, 서버 상태와 무관하게 같은 데이터셋으로 바로 연습할 수 있어야 했습니다. 셋째, JOIN과 GROUP BY가 의미 있게 동작하도록 관계와 분포가 있는 데이터가 준비되어야 했습니다. 넷째, 같은 결과를 만드는 여러 SQL을 정답으로 인정해야 했습니다.
이 조건을 정하고 나니 무엇을 서버에 두고 무엇을 브라우저에 둘지도 자연스럽게 나뉘었습니다. SQL 실행 자체는 브라우저에서 처리하고, 여러 기기에서 이어서 봐야 하는 학습 진도와 사용자 설정, 커뮤니티 글처럼 저장이 필요한 데이터만 클라우드에 두기로 했습니다. “어떤 기술을 쓸까”보다 “사용자가 첫 쿼리까지 몇 단계를 거쳐야 할까”를 먼저 기준으로 삼은 결정이었습니다.
브라우저 안에서 SQL을 실행하기 위해 sql.js를 사용했습니다. sql.js는 SQLite를 웹어셈블리(WebAssembly)로 컴파일해 브라우저에서 실행할 수 있게 해줍니다. 사용자가 SQL을 입력하면 쿼리가 별도의 실행 서버로 전송되는 대신, 현재 브라우저 메모리에 올라온 SQLite 데이터베이스에서 처리됩니다.
이 구조의 가장 큰 장점은 시작 과정이 짧다는 점입니다. 계정을 만들거나 DB 접속 정보를 입력하지 않아도 됩니다. 서버에 사용자별 실습 데이터베이스를 만들고 정리하는 운영 부담도 줄어듭니다. 잘못된 UPDATE나 DELETE를 실행해도 다른 사용자의 데이터에 영향을 주지 않습니다. 현재 SQL 스튜디오의 작업 데이터베이스는 브라우저의 IndexedDB에 저장하고, 문제 풀이는 매번 초기 데이터 복사본을 사용하는 샌드박스에서 실행해 실습과 채점이 서로 오염되지 않게 했습니다.

const SQL = await initSqlJs({
locateFile: () => '/sql-wasm.wasm'
});
const db = new SQL.Database();
장점만 있었던 것은 아닙니다. 웹어셈블리 파일과 SQL 엔진을 첫 화면부터 불러오면, 사용자는 아직 쿼리를 실행하지도 않았는데 로딩을 기다려야 했습니다. 실제로 메인 화면에 들어왔을 때 체감 속도가 느려지는 문제가 생겼습니다.
해결 방법은 SQL 엔진이 필요한 경로를 분리하는 것이었습니다. 홈, 학습 목록, 진행률처럼 DB 실행이 필요 없는 화면은 바로 렌더링하고, SQL 스튜디오·문제 상세·테이블 탐색에 들어갈 때만 sql.js와 웹어셈블리를 불러오도록 바꿨습니다. 페이지 코드도 지연 로딩했습니다. 기술적으로는 작은 분리처럼 보이지만, “설치 없이 바로 시작한다”는 서비스의 약속을 지키려면 첫 화면의 기다림부터 줄여야 했습니다.
SQLite를 선택하면서 생기는 범위도 분명히 했습니다. Oracle의 DECODE나 계층형 쿼리처럼 DBMS마다 다른 문법을 모두 그대로 실행할 수는 없습니다. 현재 서비스는 공통 SQL과 SQLite에서 실행 가능한 문법을 중심으로 실습하고, SQLD 이론 문제에서는 DBMS별 차이를 설명하는 방식으로 역할을 나눴습니다. 모든 DBMS를 흉내 내기보다 입문자가 관계형 데이터와 쿼리 결과를 빠르게 이해하도록 돕는 쪽을 선택했습니다.
실행창만 만든다고 JOIN을 연습할 수 있는 것은 아니었습니다. 그래서 departments, employees, products, orders, customers, sales까지 6개 테이블을 구성했습니다. 부서와 직원, 직원과 주문, 주문과 상품, 고객과 판매처럼 관계를 따라갈 수 있게 외래키를 연결했습니다. 현재 기본 데이터는 부서 4건, 직원 10건, 상품 8건, 주문 12건, 고객 15건, 판매 20건으로 구성되어 있습니다.
데이터를 만들 때는 행 수보다 “질문을 만들 수 있는가”를 기준으로 보았습니다. 모든 직원의 급여가 비슷하면 평균 이상 직원을 찾는 문제가 단순해집니다. 모든 부서의 인원수가 같으면 GROUP BY 결과가 재미없어집니다. 주문이 특정 직원에게만 몰리거나, 주문하지 않은 직원이 있어야 LEFT JOIN과 서브쿼리의 차이가 눈에 들어옵니다. 결국 학습용 데이터는 예쁘게 채운 샘플이 아니라, 쿼리에 따라 결과가 달라지도록 설계한 문제의 일부였습니다.
스키마를 코드에 설명문으로만 적지 않고 실제 SQLite 메타데이터에서 읽어오도록 했습니다. 테이블 탐색 화면은 컬럼 타입과 샘플 행을 보여주고, PRAGMA로 외래키 관계를 읽어 ER 다이어그램(ERD)을 그립니다. 사용자가 에디터 옆 스키마와 ERD를 오가며 “어떤 키로 연결해야 하는지”를 직접 확인할 수 있도록 한 것입니다.

문제 풀이 기능에서 가장 오래 고민한 부분은 채점이었습니다. 처음 떠올리기 쉬운 방법은 사용자의 SQL 문자열과 정답 SQL을 비교하는 것입니다. 하지만 SQL은 같은 결과를 만드는 표현이 많습니다. 테이블 별칭을 e로 쓰든 emp로 쓰든 결과는 같습니다. 조건의 순서를 바꾸거나 AS를 생략해도 결과가 같을 수 있습니다. JOIN 순서가 달라도 실행 결과가 동일한 경우가 있습니다.
예를 들어, 아래 두 쿼리는 문장은 다르지만 같은 데이터를 조회합니다.

SELECT name, salary
FROM employees
WHERE salary >= 6000000;
SELECT e.name, e.salary
FROM employees AS e
WHERE 6000000 <= e.salary;
문자열 비교를 그대로 사용하면 학습자는 맞는 쿼리를 작성하고도 오답을 받습니다. 그래서 사용자 쿼리와 정답 쿼리를 각각 초기 데이터베이스 복사본에서 실행하고, 결과의 행 수와 열 수, 각 셀의 값을 비교하도록 바꿨습니다. 순서가 중요한 문제는 정렬 순서까지 검사하고, 그렇지 않은 문제는 행을 정규화한 뒤 순서와 무관하게 비교합니다. 문제에서 컬럼 별칭을 요구할 때는 열 이름도 엄격하게 확인합니다.
결과만 같으면 끝이라고 생각했지만, 여기에도 빈틈이 있었습니다. 누군가 현재 데이터에 보이는 값을 SELECT로 직접 나열하면 우연히 정답 결과와 같아질 수 있습니다. 이를 막기 위해 부서명·급여·가격·주문 수량을 바꾸고 검증용 행을 추가한 두 번째 데이터베이스를 만들었습니다. 첫 데이터에서 통과한 쿼리는 변형 데이터에서도 정답 쿼리와 같은 결과를 내야 최종 정답으로 인정합니다. 화면의 “변경된 데이터에서도 조건에 맞게 동작합니다”라는 피드백은 이 검사를 통과했다는 뜻입니다.
여기에 문제마다 요구한 테이블과 JOIN, GROUP BY, 윈도우 함수 같은 핵심 문법을 실제로 사용했는지 확인하고, INSERT·UPDATE·DELETE 같은 변경 쿼리는 채점에서 막았습니다. 결과 비교만으로는 학습 목표와 무관한 우회 풀이를 모두 구분할 수 없기 때문입니다. 현재 채점은 “결과가 같은가”와 “이 문제에서 연습하려는 방법을 사용했는가”를 함께 봅니다.
브라우저에서 모든 SQL을 실행한다고 해서 서버가 전혀 필요 없는 것은 아니었습니다. 로그인한 사용자가 다른 기기에서도 진도와 설정을 이어서 보려면 동기화가 필요했고, 커뮤니티 글과 댓글은 사용자 사이에 공유되어야 했습니다. 이 부분에는 Firebase Authentication과 Cloud Firestore를 사용했습니다.
반대로 SQL 실행 결과와 학습용 데이터베이스까지 Firestore로 보내지는 않았습니다. 실습 쿼리는 브라우저의 SQLite에서 실행하고, Firestore에는 사용자별 진행률·학습 기록·설정과 커뮤니티 데이터만 저장합니다. 역할을 이렇게 나누면 쿼리 실행을 위한 서버 API를 따로 운영하지 않아도 되고, 실습 데이터가 사용자마다 섞일 위험도 줄일 수 있습니다.
저장 구조를 나눈 뒤에는 보안 규칙도 함께 정리했습니다. 사용자 진도와 설정은 본인만 읽고 쓸 수 있게 했고, 커뮤니티는 읽기는 공개하되 글 작성·수정·삭제는 인증과 작성자 정보를 확인합니다. 좋아요와 댓글도 변경할 수 있는 필드를 제한했습니다. Firebase를 썼다는 사실보다, 어떤 데이터가 공유되어야 하고 누가 바꿀 수 있는지를 먼저 구분하는 일이 더 중요했습니다.
처음에는 SQL 에디터와 실행 버튼이 있으면 서비스가 성립한다고 생각했습니다. 실제로 만들어 보니 사용자는 “무엇을 입력해야 하는지”, “왜 틀렸는지”, “다음에는 무엇을 풀어야 하는지”에서 더 자주 멈췄습니다. 그래서 에디터에는 스키마 기반 자동완성, 여러 쿼리 탭, 실행 기록, 저장한 쿼리, SQL 포맷, 실행 계획 확인, 결과 필터·통계·차트·내보내기를 추가했습니다. 입문·편의성·터미널 모드로 도움의 정도도 나눴습니다.
문제 풀이에서는 번역한 오류 메시지, 단계별 힌트, 사용 테이블, 채점 체크 항목을 보여줍니다. 틀린 문제와 다시 풀 문제를 복습 대기로 모으고, 홈에서는 복습 표시·미완료 여부·난이도를 기준으로 다음 문제를 추천합니다. 학습 진도는 게스트 상태에서는 로컬에 저장하고, 로그인하면 Firestore와 동기화합니다.
기획안을 처음 보냈을 때는 SQLD 연습과 ERD 기능을 앞으로 추가할 계획이라고 적었습니다. 현재는 두 기능 모두 서비스에 들어가 있습니다. SQLD·SQLP 학습 정보와 SQLD 예상 문제·모의시험을 제공하고, 테이블 탐색에서는 6개 테이블의 관계를 ERD로 확인할 수 있습니다. 학습 주제는 SELECT부터 윈도우 함수와 집합 연산까지 11개, 실습 문제는 입문부터 고급까지 32개입니다. 발행 시점에는 계획이 아니라 구현된 상태로 소개해야 하는 이유입니다.
이 프로젝트를 만들기 전에는 SQL 학습 서비스의 중심이 좋은 에디터라고 생각했습니다. 지금은 생각이 달라졌습니다. 입문자에게 더 중요한 것은 연습할 관계형 데이터, 실패해도 복구되는 실행 환경, 여러 형태의 정답을 받아들이는 채점, 다음 행동을 알려주는 피드백입니다. 에디터는 이 흐름을 연결하는 도구였습니다.
또 하나 배운 점은 브라우저에서 실행된다는 사실만으로 곧바로 가벼운 서비스가 되지는 않는다는 것입니다. 웹어셈블리를 언제 불러올지, 작업 데이터와 채점 데이터를 어떻게 분리할지, 브라우저 저장소가 깨졌을 때 어떻게 초기화할지까지 함께 설계해야 했습니다. 서버를 줄이면 복잡성이 사라지는 것이 아니라 다른 위치로 이동합니다.
앞으로는 현재 6개 테이블 외에 업무 도메인이 다른 데이터셋을 추가하고, 하나의 시나리오를 여러 쿼리로 분석하는 연속 과제를 만들 계획입니다. ERD도 보는 기능에서 끝내지 않고, 관계를 해석한 뒤 실제 JOIN 문제로 이어지도록 연결하고 싶습니다. 다만 기능 수를 늘리는 것보다, 사용자가 설치 없이 들어와 첫 쿼리를 실행하고 틀린 이유를 이해한 뒤 다음 문제로 넘어가는 흐름을 먼저 유지하려 합니다.
대학교에서 겪었던 불편함은 “설치가 귀찮았다”로 끝나는 작은 문제가 아니었습니다. SQL을 배우려는 사람이 문법보다 환경 준비에 먼저 지치는 문제였습니다. NULL지마는 그 준비 과정을 브라우저 안으로 옮기고, 미리 만든 관계형 데이터와 결과 기반 채점을 붙여 해결해 본 프로젝트입니다.
서비스를 만들며 가장 오래 붙잡은 질문은 “쿼리가 실행되는가”가 아니었습니다. “처음 온 사람이 바로 연습할 수 있는가”, “표현이 다른 정답을 공정하게 인정하는가”, “현재 데이터에만 맞춘 편법을 걸러낼 수 있는가”였습니다. 이 질문들이 sql.js·SQLite, Firebase 분리, 변형 데이터 재검증이라는 기술적 선택으로 이어졌습니다.
아직 보완할 부분은 많지만, 적어도 과거의 저처럼 DB 설치와 샘플 데이터 준비에서 막힌 사람은 주소 하나만 열고 SELECT와 JOIN을 시작할 수 있게 됐습니다. 이 프로젝트가 보여주고 싶은 것도 거창한 기술보다 그 변화입니다.
*이 글은 AI의 도움을 받아 작성했습니다. 서비스 아이디어와 핵심 구조, 학습용 데이터셋과 문제 구성, 채점 방식 및 기술 선택은 직접 정했습니다. 개발 과정에서 AI는 코드 초안, 오류 원인 확인, 반복 구현을 보조했고, 원고에서는 구조 정리와 문장 다듬기에 활용했습니다. 최종 내용은 현재 프로젝트 코드와 배포 화면을 기준으로 문장 단위로 직접 검수했습니다.
<참고>
ⓒ요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.