Vue 3와 Spring Boot 운영 SQL 콘솔 앞의 데이터베이스를 방패로 보호하는 mLog 유령 캐릭터

Vue 3·Spring Boot 운영 SQL 콘솔 실전 설계: CRUD에서 보안·감사·Excel까지

개발 vue 2026년 9월 3일

처음 질문은 평범했습니다. Vue 3 화면과 Spring Boot API를 연결해 조회·등록·수정·삭제를 구현하는 방법이었습니다. 게시판 예제로 Entity → Repository → Controller → Axios를 연결하면 금방 끝날 것처럼 보였습니다.

그런데 실제 목표를 다시 확인하자 전혀 다른 도구가 나타났습니다. 운영 담당자가 웹에서 SQL을 직접 입력하고, 결과를 동적 그리드로 확인하며, 필요하면 Excel로 내려받는 사내 운영 콘솔이었습니다. 여기에 내부망 제한, SUPER_ADMIN, 6자리 OTP, 긴 셀 펼치기, 20행 스크롤, KST 변환까지 요구가 이어졌습니다.

기능은 하나씩 붙었습니다. SQL도 실행됐고 결과 표도 보였습니다. 하지만 “동작한다”는 사실이 “운영에 안전하다”는 뜻은 아니었습니다.

  • SELECT로 시작하는지만 확인한 뒤 나머지를 모두 변경 쿼리로 실행하면 어떻게 될까?
  • DROP, TRUNCATE 같은 단어를 막으면 정말 안전할까?
  • X-Forwarded-For를 믿고 내부망 여부를 판정해도 될까?
  • 화면에 OTP 입력칸을 추가하면 2차 인증이 완성될까?
  • SXSSFWorkbook(100)을 쓰면 10만 행도 메모리 걱정 없이 내려받을 수 있을까?
  • LocalDateTime에 무조건 9시간을 더하면 KST가 될까?

답은 대부분 “아니다”였습니다. 이 글은 완성 화면만 보여주는 튜토리얼이 아닙니다. 처음 만든 코드에서 무엇이 실패했고, 왜 실패했으며, 운영 환경에 맞추려면 보안 경계를 어디에 다시 세워야 하는지를 정리한 구축 기록입니다.

주의: 운영 DB에 임의 SQL을 실행하는 기능은 작은 실수 하나로 데이터 유출·삭제·서비스 장애를 만들 수 있습니다. 가능하면 VPN 또는 Zero Trust 뒤에서 검증된 DB 관리 도구를 사용하세요. 직접 구축해야 한다면 기본 기능을 읽기 전용으로 제한하고, 변경 실행은 별도의 승인·복구 절차로 분리해야 합니다.

3줄 요약

  • 읽기와 변경 실행을 분리하고, 읽기 전용 DB 계정을 최종 방어선으로 둡니다.
  • 내부망·역할·CSRF·OTP는 서로를 대체하지 않습니다. 각각 독립된 방어층으로 서버에서 검증해야 합니다.
  • Vue 그리드, Excel, KST 표시는 사용성 문제입니다. 조회량·권한·시간 의미를 백엔드와 DB에서 먼저 확정해야 안전합니다.

목차

  1. 5분 빠른 시작: 코드보다 먼저 정할 일
  2. 첫 구현은 왜 위험했나
  3. 읽기와 변경 실행을 분리한다
  4. 내부망과 프록시 IP를 올바르게 다룬다
  5. SUPER_ADMIN·세션·CSRF·OTP
  6. Spring Boot 읽기 전용 실행 골격
  7. Vue 결과 그리드의 실패와 개선
  8. Monaco Editor가 오히려 개발을 멈춘 이유
  9. 대용량 Excel과 SXSSFWorkbook의 오해
  10. KST 변환 전에 시간의 의미를 정한다
  11. 감사 로그와 운영 실패 테스트
  12. 성공한 것·실패한 것·최종 체크리스트
  13. FAQ

5분 빠른 시작: 코드보다 먼저 정할 일

운영 콘솔은 화면부터 만들면 거의 반드시 되돌아오게 됩니다. 아래 일곱 가지를 먼저 문서로 확정하는 편이 빠릅니다.

1. 기본 모드는 읽기 전용인가

대답은 가능하면 “예”여야 합니다. 조회 콘솔의 DB 계정에는 필요한 스키마의 SELECT만 부여합니다. 애플리케이션이 실수로 DELETE를 실행하려 해도 DB가 거절해야 합니다.

2. 한 번에 얼마나 읽을 수 있는가

화면 조회와 Excel 다운로드의 제한은 달라야 합니다. 예를 들면 화면은 1,001행을 읽어 1,000행까지만 보여주고 “더 있음”을 표시합니다. Excel은 업무상 허용된 별도 상한, 파일 크기 상한, 동시 실행 수를 둡니다.

3. 얼마나 오래 실행할 수 있는가

HTTP 타임아웃만 설정해서는 부족합니다. JDBC Statement 타임아웃과 DB 서버의 statement timeout을 함께 둬야 연결이 끊긴 뒤에도 쿼리가 계속 달리는 상황을 줄일 수 있습니다.

4. 누가 어디에서 접근하는가

SSO로 인증된 사용자, 서버가 확인한 역할, 승인된 네트워크를 각각 검사합니다. 관리자 메뉴를 숨기는 Vue 조건문은 사용성 기능이지 권한 검사가 아닙니다.

5. 변경 쿼리는 어떻게 처리할 것인가

조회 API에 쓰기 기능을 섞지 않습니다. 꼭 필요하다면 별도 데이터소스·별도 엔드포인트·짧은 수명의 실행권·사유와 티켓·영향 행 상한·승인·트랜잭션·복구 계획을 갖춘 break-glass 절차로 분리합니다.

6. 무엇을 감사 로그에 남길 것인가

실행자, 승인된 클라이언트 IP, 대상 환경, 시간, 처리 시간, 행 수, 성공 여부, 정책 결정, 요청 ID가 기본입니다. OTP와 결과 데이터, 비밀번호 같은 비밀은 남기지 않습니다.

7. 사고가 나면 어떻게 끌 것인가

배포 없이 콘솔을 비활성화하는 kill switch, DB 계정 잠금 절차, 진행 중 쿼리 취소 권한과 담당자를 정합니다. 가장 중요한 버튼은 실행 버튼이 아니라 “즉시 중지” 절차일 수 있습니다.

첫 구현은 왜 위험했나

초기 구현은 아래와 같은 생각에서 출발했습니다.

// 위험한 초기 발상 — 운영 코드로 사용하면 안 된다.
if (sql.trim().toLowerCase().startsWith("select")) {
    return jdbcTemplate.queryForList(sql);
}
return Map.of("updatedRows", jdbcTemplate.update(sql));

짧고 이해하기 쉽지만 보안 정책은 아닙니다.

startsWith("select")는 SQL의 의미를 판정하지 못한다

정상 조회도 주석이나 CTE 때문에 SELECT로 시작하지 않을 수 있습니다. 반대로 SELECT 문 안에서 부수효과가 있는 함수를 호출할 수 있는 DB도 있습니다. 다중 문장, 저장 프로시저, DB별 문법까지 고려하면 첫 단어만으로 읽기와 쓰기를 구분할 수 없습니다.

금지어 목록도 최종 방어선이 될 수 없다

DROP, TRUNCATE, ALTER를 문자열로 찾는 방식은 주석·공백·인용·대소문자·DB 방언에 흔들립니다. 금지어를 계속 늘려도 허용 의도를 증명하지 못합니다. 파서는 입력을 분류하는 보조 장치로 사용할 수 있지만, 파서가 DB 권한을 대신해서는 안 됩니다.

PreparedStatement도 SQL 구조 전체를 안전하게 만들지 않는다

값 자리에 바인딩 변수를 쓰는 것은 SQL 주입 방어의 기본입니다. 그러나 이 콘솔처럼 사용자가 SQL 문장 전체를 작성하는 경우에는 SQL 구조 자체를 파라미터로 바인딩할 수 없습니다. “PreparedStatement를 썼으니 안전하다”는 결론을 내리면 안 됩니다.

오류 원문을 그대로 반환하면 내부 구조가 샌다

exception.getMessage()에는 테이블·컬럼·제약 조건·SQL 일부와 드라이버 정보가 포함될 수 있습니다. 브라우저에는 일반화된 메시지와 요청 ID만 주고, 상세 원인은 접근이 제한된 서버 로그에서 요청 ID로 찾아야 합니다.

화면의 초록색 Secure 배지는 증거가 아니다

초기 Vue 코드에는 isSecure = ref(true) 같은 상태로 보안 배지를 켜는 방식도 있었습니다. 실제 세션, 역할, 네트워크, 실행 정책을 서버에서 확인한 결과가 아니라면 장식일 뿐입니다. 오히려 운영자에게 잘못된 확신을 줄 수 있어 제거하는 편이 낫습니다.

읽기와 변경 실행을 분리한다

가장 큰 개선은 복잡한 SQL 정규식이 아니라 권한을 가진 통로 자체를 나누는 것입니다.

읽기 콘솔

  • 엔드포인트: /api/admin/query/read
  • 데이터소스: 전용 읽기 계정
  • DB 권한: 필요한 스키마·뷰의 SELECT
  • 정책: 단일 조회 문장, 허용 스키마, 최대 행·열·셀·응답 바이트
  • 제한: statement timeout, 동시 실행 수, 사용자별 속도 제한
  • 출력: 화면용 제한 결과 또는 승인된 Excel 다운로드

변경 실행

  • 기본값: 비활성화
  • 엔드포인트와 DB 계정: 읽기 콘솔과 완전 분리
  • 조건: 작업 티켓, 사유, 재인증, 영향 범위 미리보기, 추가 승인
  • 실행: 짧은 트랜잭션, 영향 행 상한, 명확한 commit 시점
  • 복구: 백업 또는 PITR 가능 여부를 실행 전에 확인
  • 감사: 승인자와 실행자를 함께 기록

애플리케이션의 Connection#setReadOnly(true)@Transactional(readOnly = true)는 드라이버와 DB가 최적화 힌트로 다룰 수 있어 유용하지만, 그것만 믿어서는 안 됩니다. 권한이 제한된 전용 DB 계정이 마지막 방어선입니다.

내부망과 프록시 IP를 올바르게 다룬다

초기 구현에서는 X-Forwarded-For를 읽어 사내 IP인지 확인하는 필터가 등장했습니다. 가장 흔한 실수는 인터넷에서 앱 서버로 직접 들어올 수 있는 상태에서 이 헤더를 무조건 신뢰하는 것입니다. 공격자가 헤더 값을 직접 만들어 보낼 수 있기 때문입니다.

권장 흐름은 다음과 같습니다.

  1. 방화벽 또는 보안 그룹에서 애플리케이션 서버의 직접 접근을 막습니다.
  2. 요청은 승인된 로드밸런서·리버스 프록시를 통해서만 들어옵니다.
  3. 프록시는 외부에서 들어온 전달 헤더를 제거하고 신뢰 가능한 값으로 다시 씁니다.
  4. 애플리케이션은 알려진 프록시에서 온 요청에 대해서만 전달 헤더를 해석합니다.
  5. 단일 IP 문자열 비교 대신 IPv4·IPv6 CIDR 정책을 사용합니다.
  6. IP 통과 뒤에도 사용자 인증과 역할 검사를 별도로 수행합니다.

사내망 IP는 계정을 대신하지 않습니다. VPN 계정이 탈취되거나 내부 단말이 감염될 수 있고, NAT 뒤의 여러 사용자가 같은 주소로 보일 수도 있습니다. 네트워크는 한 겹의 문이지 신원 그 자체가 아닙니다.

SUPER_ADMIN·세션·CSRF·OTP

역할은 URL이 아니라 서버가 강제한다

Vue에서 메뉴를 숨기고 라우터 가드를 걸어도 API를 직접 호출할 수 있습니다. Spring Security에서 요청 경로와 메서드 권한을 강제하고, 필요하면 서비스 메서드에도 방어 검사를 둡니다.

@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(HttpMethod.POST, "/api/admin/query/**")
                .hasRole("SUPER_ADMIN")
            .anyRequest().authenticated()
        )
        // 세션·쿠키 인증이라면 CSRF 보호를 유지한다.
        .csrf(Customizer.withDefaults())
        .build();
}

이 코드는 인증 방식 전체를 보여주는 완성 설정이 아닙니다. 핵심은 “API니까 보통 CSRF를 끈다”는 습관을 버리는 것입니다. 브라우저가 세션 쿠키를 자동으로 전송하는 구조라면 CSRF 위협이 남습니다. 정말로 쿠키를 쓰지 않는 stateless Bearer 구조인지, 브라우저 저장·전송 방식이 무엇인지 확인한 뒤 결정해야 합니다.

OTP 입력칸은 2차 인증이 아니다

Vue에 아래 필드를 추가하면 OTP 값을 서버로 보낼 수는 있습니다.

<input
  v-model="otpCode"
  inputmode="numeric"
  autocomplete="one-time-code"
  maxlength="6"
  aria-label="일회용 인증 코드"
/>

하지만 서버 검증, 만료, 재사용 방지, 실패 횟수 제한이 없다면 보안 기능은 완성되지 않았습니다. 더 안전한 방식은 OTP를 SQL 실행 요청에 매번 섞는 대신 짧은 수명의 일회용 실행권으로 바꾸는 것입니다.

  1. 사용자가 작업 사유와 OTP로 step-up 인증을 요청합니다.
  2. 서버는 TOTP를 검증하고 실패 횟수와 재사용 여부를 확인합니다.
  3. 서버는 사용자·대상 환경·작업 종류·쿼리 해시에 묶인 30~60초짜리 일회용 실행권을 발급합니다.
  4. 실행 API는 실행권의 범위와 만료, 1회 사용 여부를 검증합니다.
  5. 성공 여부와 무관하게 사용된 실행권은 다시 쓸 수 없게 합니다.

OTP 값은 로그, 오류 메시지, 분석 도구, 브라우저 URL에 남기지 않습니다. TLS는 당연한 전제이고, OTP 검증 엔드포인트에는 속도 제한과 계정 잠금 정책이 필요합니다.

Spring Boot 읽기 전용 실행 골격

여기서는 임의 DDL·DML 실행기의 완성 코드를 제공하지 않습니다. 복사 가능한 쓰기 콘솔은 잘못된 환경에 붙는 순간 사고 도구가 될 수 있기 때문입니다. 대신 읽기 전용 계정과 정책 검증을 전제로 한 조회 골격만 살펴보겠습니다.

요청과 응답 계약부터 제한을 드러낸다

결과를 List<Map<String, Object>> 하나로 보내면 컬럼 순서, 중복 label, 잘림 여부를 표현하기 어렵습니다. 컬럼 메타데이터와 행 배열을 분리하면 동적 그리드에서도 계약이 선명해집니다.

public record ReadQueryRequest(String sql) {}

public record QueryColumn(
    int index,
    String label,
    String jdbcType
) {}

public record QueryResult(
    String requestId,
    List<QueryColumn> columns,
    List<List<Object>> rows,
    boolean truncated,
    long elapsedMs
) {}

Map의 key로 컬럼명을 쓰면 동일한 label이 두 번 등장할 때 앞의 값이 덮어써질 수 있습니다. columns와 위치 기반 rows를 쓰면 같은 label도 인덱스로 구분할 수 있습니다.

문자열 차단기가 아니라 승인된 조회 계획을 받는다

SQL 파서와 정책 엔진은 프로젝트마다 허용 DB·스키마·함수가 다르므로 여기서 가짜 “완성 파서”를 만들지 않습니다. 서비스는 정책 검사를 통과한 단일 조회만 받는다고 명시합니다.

public interface ReadQueryPolicy {
    ApprovedReadQuery approve(String rawSql, AuthenticatedActor actor);
}

public record ApprovedReadQuery(
    String sql,
    String queryHash,
    int maxRows,
    int timeoutSeconds
) {}

정책이 확인해야 할 최소 항목은 다음과 같습니다.

  • SQL 길이와 문자 인코딩
  • 단일 문장 여부
  • 조회 계열 문장인지 여부
  • 허용 DB·스키마·뷰·함수인지 여부
  • 민감 컬럼 마스킹 정책
  • 사용자별 조회 범위
  • 화면 조회와 Excel 다운로드의 서로 다른 상한

그럼에도 정책 엔진은 보조 방어입니다. 실행 데이터소스의 DB 계정에 쓰기 권한이 있으면 설계가 무너집니다.

행·시간 제한은 요청 단위 Statement에 건다

공유 JdbcTemplate의 전역 maxRows 값을 요청마다 바꾸면 동시 요청끼리 설정이 섞일 수 있습니다. 요청별 PreparedStatement에 제한을 설정합니다.

@Service
@RequiredArgsConstructor
public class ReadQueryService {

    private final JdbcTemplate readOnlyJdbcTemplate;
    private final ReadQueryPolicy policy;
    private final QueryAuditService audit;

    @PreAuthorize("hasRole('SUPER_ADMIN')")
    public QueryResult execute(
        ReadQueryRequest request,
        AuthenticatedActor actor
    ) {
        ApprovedReadQuery approved = policy.approve(request.sql(), actor);
        String requestId = UUID.randomUUID().toString();
        long started = System.nanoTime();

        try {
            QueryResult result = readOnlyJdbcTemplate.execute(connection -> {
                try (PreparedStatement statement = connection.prepareStatement(
                    approved.sql(),
                    ResultSet.TYPE_FORWARD_ONLY,
                    ResultSet.CONCUR_READ_ONLY
                )) {
                    statement.setMaxRows(approved.maxRows() + 1);
                    statement.setFetchSize(200);
                    statement.setQueryTimeout(approved.timeoutSeconds());

                    try (ResultSet rs = statement.executeQuery()) {
                        return mapPreview(
                            requestId,
                            rs,
                            approved.maxRows(),
                            started
                        );
                    }
                }
            });

            audit.success(requestId, actor, approved, result);
            return result;
        } catch (DataAccessException ex) {
            audit.failure(requestId, actor, approved, ex);
            throw new QueryExecutionException(
                "쿼리를 처리하지 못했습니다. 요청 ID: " + requestId
            );
        }
    }
}

이 예시에서도 꼭 알아야 할 제한이 있습니다.

  • setFetchSize()는 JDBC 드라이버에 주는 힌트입니다. 실제 스트리밍 조건은 PostgreSQL, MySQL 등 드라이버마다 다릅니다.
  • setMaxRows()는 반환 행 수를 제한하지만 비효율적인 쿼리의 실행 비용 자체를 없애지 않습니다.
  • setQueryTimeout()의 취소 동작도 드라이버와 DB에 영향을 받습니다.
  • 따라서 DB 서버의 timeout, 전용 connection pool, 동시 실행 제한을 함께 사용해야 합니다.
  • PreparedStatement를 사용했어도 approved.sql()은 사용자 작성 구조입니다. 정책 검사와 DB 최소 권한이 여전히 필수입니다.

조회 트랜잭션에는 readOnly = true를 사용할 수 있지만, 이것 역시 DB 계정의 권한 제한을 대체하지 않습니다.

@Transactional(readOnly = true, timeout = 10)
public QueryResult execute(...) {
    // 승인된 단일 조회만 실행
}

프레임워크의 read-only 표시가 힌트에 그치는 조합도 있습니다. DB가 지원한다면 트랜잭션 read-only를 실제로 강제하는 설정을 검토하고, 그 위에 SELECT 전용 계정을 겹칩니다.

Vue 결과 그리드의 실패와 개선

백엔드 경계를 세운 뒤에는 운영자가 실수하지 않도록 화면을 다듬을 차례입니다. 최종 UI는 Monaco가 아니라 기본 <textarea>를 유지했습니다. 설치 패키지와 이벤트 API 문제로 핵심 기능이 멈춘 상황에서, 가장 단순하고 예측 가능한 입력기로 돌아간 선택입니다.

실행 요청은 중복 실행부터 막는다

버튼의 disabled만으로는 Ctrl+Enter나 다른 이벤트 경로의 재진입을 막을 수 없습니다. 함수 첫 줄에서도 실행 중 상태를 확인합니다.

<script setup lang="ts">
import { computed, ref } from 'vue'
import axios from 'axios'

interface QueryColumn {
  index: number
  label: string
  jdbcType: string
}

interface QueryResult {
  requestId: string
  columns: QueryColumn[]
  rows: unknown[][]
  truncated: boolean
  elapsedMs: number
}

const sql = ref('')
const running = ref(false)
const result = ref<QueryResult | null>(null)
const errorMessage = ref('')
const expandedCells = ref(new Set<string>())

const canRun = computed(() =>
  sql.value.trim().length > 0 && !running.value
)

async function runQuery(): Promise<void> {
  if (!canRun.value) return

  running.value = true
  errorMessage.value = ''
  result.value = null

  try {
    const response = await axios.post<QueryResult>(
      '/api/admin/query/read',
      { sql: sql.value },
      { timeout: 15_000 }
    )
    result.value = response.data
  } catch (error) {
    errorMessage.value = toSafeMessage(error)
  } finally {
    running.value = false
  }
}

function cellKey(rowIndex: number, columnIndex: number): string {
  return `${rowIndex}:${columnIndex}`
}

function toggleCell(rowIndex: number, columnIndex: number): void {
  const next = new Set(expandedCells.value)
  const key = cellKey(rowIndex, columnIndex)
  next.has(key) ? next.delete(key) : next.add(key)
  expandedCells.value = next
}

async function copyOriginal(value: unknown): Promise<void> {
  await navigator.clipboard.writeText(value == null ? '' : String(value))
}
</script>

axios의 브라우저 timeout은 사용자 경험을 위한 제한입니다. 서버나 DB에서 실행 중인 쿼리가 자동으로 취소된다고 가정하면 안 됩니다.

event.target 때문에 셀 펼침이 실패했다

초기 코드는 클릭된 요소에 직접 클래스를 붙였습니다.

@click="$event.target.classList.toggle('expanded')"

셀 안의 <span>을 클릭하면 target은 셀 컨테이너가 아니라 자식 요소가 됩니다. 그래서 .cell-content.expanded 규칙이 적용되지 않았습니다. currentTarget으로 고칠 수 있지만, Vue에서는 DOM 클래스를 직접 바꾸기보다 위 예시처럼 행·열 key를 상태로 관리하는 편이 재렌더링에도 안정적입니다.

<div class="result-grid" role="region" aria-label="쿼리 결과" tabindex="0">
  <table v-if="result">
    <thead>
      <tr>
        <th v-for="column in result.columns" :key="column.index">
          {{ column.label }}
        </th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="(row, rowIndex) in result.rows" :key="rowIndex">
        <td v-for="(value, columnIndex) in row" :key="columnIndex">
          <button
            class="cell-content"
            :class="{ expanded: expandedCells.has(cellKey(rowIndex, columnIndex)) }"
            type="button"
            @click="toggleCell(rowIndex, columnIndex)"
          >
            {{ value ?? '(null)' }}
          </button>
          <button type="button" @click="copyOriginal(value)">
            복사
          </button>
        </td>
      </tr>
    </tbody>
  </table>
</div>

원본 복사는 화면에 보이는 말줄임 문자열이 아니라 상태에 보관된 전체 값을 사용합니다.

모바일에서는 표를 압축하지 않는다

컬럼이 많은 결과를 화면 폭에 억지로 맞추면 PC와 모바일 모두 읽기 어려워집니다. 표는 최소 너비를 갖게 하고 컨테이너에서 가로로 스크롤합니다. 헤더는 세로 스크롤 중에도 유지합니다.

.result-grid {
  width: 100%;
  max-height: 36rem;
  overflow: auto;
  border: 1px solid #273244;
  border-radius: 0.75rem;
  -webkit-overflow-scrolling: touch;
}

.result-grid table {
  min-width: 48rem;
  width: max-content;
  border-collapse: separate;
  border-spacing: 0;
}

.result-grid th {
  position: sticky;
  top: 0;
  z-index: 2;
  background: #111827;
}

.cell-content {
  display: block;
  max-width: 14rem;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.cell-content.expanded {
  max-width: min(42rem, 80vw);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

max-height: 600px은 “정확히 20행”을 보장하지 않습니다. 폰트, 줄 높이, 확대 비율에 따라 보이는 행 수가 달라집니다. 꼭 20행이어야 한다면 행 높이를 고정해 계산하거나 가상 스크롤을 사용해야 합니다.

화면의 performance.now() 값은 DB 실행시간이 아니라 네트워크와 JSON 처리까지 포함한 브라우저 왕복시간입니다. 서버가 별도로 측정한 elapsedMs와 구분해 표시하면 문제를 더 빨리 찾을 수 있습니다.

Monaco Editor가 오히려 개발을 멈춘 이유

textarea가 동작한 뒤 Monaco Editor를 넣으려 했습니다. 여기서 비슷한 이름의 Vue wrapper를 혼동했습니다.

  • 처음에는 @guolao/vue-monaco-editor의 API를 전제로 코드를 작성했습니다.
  • 실제 설치된 패키지는 monaco-editor-vue3였습니다.
  • default import와 <MonacoEditor>를 가정했지만, 사용 중인 예시는 named export인 CodeEditor였습니다.
  • mount 이벤트 이름도 패키지와 버전별로 달라 @load, @mount, @editorDidMount를 추측해서 바꾸는 상황이 생겼습니다.
  • 결국 기본 textarea로 돌아가 핵심 실행·권한·결과 처리를 먼저 안정화했습니다.

교훈은 단순합니다. 패키지 이름이 비슷하다고 API도 같다고 가정하면 안 됩니다.

pnpm list monaco-editor-vue3 monaco-editor

설치 버전을 고정하고, 그 버전의 README와 TypeScript export·event 타입을 확인한 뒤 가장 작은 샘플부터 실행해야 합니다. 특히 Vite worker 설정, editor mount 시점, Ctrl/Cmd+Enter 등록 방식이 확인되기 전에는 전체 콘솔 코드를 한 번에 교체하지 않는 편이 좋습니다.

Monaco는 자동 완성, 구문 강조, 다중 선택 같은 장점이 있지만 보안 기능은 아닙니다. textarea로도 서버 정책이 완성되면 안전할 수 있고, Monaco를 써도 raw SQL을 과도한 권한으로 실행하면 위험합니다.

대용량 Excel과 SXSSFWorkbook의 오해

처음에는 Vue에서 SheetJS로 현재 결과를 Excel로 만드는 방법을 검토했습니다. 하지만 브라우저가 이미 전체 결과를 들고 있어야 하고, 사용자의 PC 성능과 메모리에 영향을 받았습니다. 그래서 Spring Boot와 Apache POI로 서버에서 생성하는 방향으로 바꿨습니다.

여기서 new SXSSFWorkbook(100)의 숫자 100을 “최대 100행만 저장한다”는 뜻으로 오해하기 쉽습니다. 실제로는 메모리에서 임의 접근할 수 있도록 유지하는 행 윈도 크기입니다. 윈도를 벗어난 오래된 행은 임시 XML 파일로 flush되고, 최종 Excel에는 계속 포함됩니다.

그런데 다음 조합은 진짜 end-to-end 스트리밍이 아닙니다.

List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);
SXSSFWorkbook workbook = new SXSSFWorkbook(100);

워크북은 일부 행만 메모리에 두더라도 queryForList()가 DB 결과 전체를 먼저 JVM 메모리에 올립니다. 10만 행에서 메모리가 터지는 원인은 POI보다 그 앞의 List일 수 있습니다.

ResultSet에서 워크북으로 한 행씩 보낸다

아래 코드는 구조를 설명하기 위한 골격입니다. 앞에서 설명한 ApprovedReadQuery, SELECT 전용 DB 계정, 권한·감사·동시성 제한이 이미 적용됐다고 가정합니다.

public void export(
    ApprovedReadQuery approved,
    OutputStream output
) throws IOException {
    SXSSFWorkbook workbook = new SXSSFWorkbook(100);
    workbook.setCompressTempFiles(true);

    try (workbook) {
        Sheet sheet = workbook.createSheet("result");

        readOnlyJdbcTemplate.query(connection -> {
            PreparedStatement statement = connection.prepareStatement(
                approved.sql(),
                ResultSet.TYPE_FORWARD_ONLY,
                ResultSet.CONCUR_READ_ONLY
            );
            statement.setFetchSize(500);
            statement.setMaxRows(EXPORT_MAX_ROWS + 1);
            statement.setQueryTimeout(EXPORT_TIMEOUT_SECONDS);
            return statement;
        }, resultSet -> {
            ResultSetMetaData meta = resultSet.getMetaData();
            int columnCount = meta.getColumnCount();
            int rowIndex = 0;

            Row header = sheet.createRow(rowIndex++);
            for (int column = 1; column <= columnCount; column++) {
                header.createCell(column - 1)
                    .setCellValue(meta.getColumnLabel(column));
            }

            while (resultSet.next()) {
                if (rowIndex > EXPORT_MAX_ROWS) {
                    throw new ExportLimitExceededException();
                }

                Row row = sheet.createRow(rowIndex++);
                for (int column = 1; column <= columnCount; column++) {
                    writeTypedCell(
                        row.createCell(column - 1),
                        resultSet.getObject(column),
                        meta.getColumnType(column)
                    );
                }
            }
            return null;
        });

        workbook.write(output);
        output.flush();
    } finally {
        // SXSSF가 만든 임시 파일을 명시적으로 정리한다.
        workbook.dispose();
    }
}

ResultSetExtractor 형태를 사용해 메타데이터를 한 번 읽고 next()로 직접 순회했습니다. RowCallbackHandler를 사용해도 되지만, 콜백 안에서 다시 next()를 호출하면 안 됩니다.

실제 구현에서는 아래 항목도 빠지면 안 됩니다.

  • PostgreSQL은 cursor fetch를 위해 autocommit off, forward-only, 양수 fetch size 같은 조건이 필요합니다.
  • MySQL은 Connector/J 설정에 따라 useCursorFetch=true와 양수 fetch size가 필요합니다.
  • fetch size는 드라이버 힌트이므로 운영 DB·드라이버 조합에서 메모리 프로파일을 측정합니다.
  • Excel 다운로드 전용 connection pool과 동시 실행 semaphore를 둡니다.
  • 임시 디스크 사용량과 남은 공간을 모니터링합니다. SXSSF 임시 XML은 원본 데이터보다 훨씬 커질 수 있습니다.
  • 압축 임시 파일은 디스크를 아끼는 대신 CPU를 더 씁니다.
  • 클라이언트가 다운로드를 중단해도 statement 취소와 dispose()가 실행되게 합니다.
  • Excel 시트 한도는 1,048,576행이지만 운영 상한은 이보다 훨씬 낮게 잡습니다.
  • 셀 텍스트, 열 수, 총 바이트에도 제한을 둡니다.
  • 숫자·날짜·불리언은 JDBC type에 맞게 기록하고, 모든 값을 toString()으로 바꾸지 않습니다.
  • 사용자 값을 수식으로 쓰지 말고 값 셀로 기록해 formula injection 경계를 유지합니다.

화면 미리보기와 Excel 다운로드는 같은 제한을 공유하지 않아야 합니다. 미리보기는 빠르고 작게, 다운로드는 더 크지만 별도 승인과 큐·동시성 제한을 갖게 하는 편이 좋습니다.

KST 변환 전에 시간의 의미를 정한다

조회 결과가 9시간 어긋나 보이자 LocalDateTime을 UTC로 간주하고 Asia/Seoul로 변환하는 코드가 등장했습니다. DB 값이 실제 UTC instant라면 맞을 수 있지만, 이미 한국 벽시계 시간을 저장한 timestamp without time zone이라면 다시 9시간이 더해집니다.

시간대 문제는 포맷 함수보다 의미를 먼저 확인해야 합니다.

먼저 네 가지를 확인한다

  1. DB 컬럼이 순간을 나타내는지, 지역 벽시계 시간을 나타내는지 확인합니다.
  2. 컬럼 type과 JDBC 드라이버가 어떤 Java type으로 반환하는지 확인합니다.
  3. DB session, JVM, 애플리케이션의 기본 time zone 의존을 제거합니다.
  4. API에서 Z 또는 +09:00 offset을 포함할지 계약으로 정합니다.

InstantOffsetDateTime은 실제 순간으로 다룰 수 있습니다. LocalDateTime에는 zone이나 offset이 없습니다. 소스 zone을 업무 규칙으로 확정하지 않고 UTC라고 추측하면 안 됩니다. LocalDate는 날짜만 뜻하므로 시차 이동 대상이 아닙니다.

private static final ZoneId KST = ZoneId.of("Asia/Seoul");
private static final DateTimeFormatter KST_FORMAT =
    DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

String presentInKst(Object value, ZoneId sourceZone) {
    if (value instanceof Instant instant) {
        return KST_FORMAT.format(instant.atZone(KST));
    }

    if (value instanceof OffsetDateTime offsetDateTime) {
        return KST_FORMAT.format(offsetDateTime.atZoneSameInstant(KST));
    }

    if (value instanceof Timestamp timestamp) {
        return KST_FORMAT.format(timestamp.toInstant().atZone(KST));
    }

    if (value instanceof LocalDateTime localDateTime) {
        // sourceZone은 추측값이 아니라 컬럼의 저장 규칙이어야 한다.
        return KST_FORMAT.format(
            localDateTime.atZone(sourceZone).withZoneSameInstant(KST)
        );
    }

    if (value instanceof LocalDate localDate) {
        return localDate.toString();
    }

    return String.valueOf(value);
}

감사 로그의 시각은 Instant.now()처럼 UTC instant로 저장하고, 화면과 Excel의 표현 경계에서만 KST로 바꾸는 방법이 가장 추적하기 쉽습니다. PostgreSQL의 timestamptz도 내부 순간과 세션 표시 time zone을 구분하므로, DB 화면에 보이는 문자열만 보고 저장값을 단정하면 안 됩니다.

감사 로그와 운영 실패 테스트

운영 콘솔은 성공 로그만 남겨서는 부족합니다. 거부, timeout, 취소, 제한 초과도 같은 request ID로 추적해야 합니다.

감사 이벤트에 남길 것

  • 실행자 ID와 인증 주체
  • 실행 시점의 역할과 step-up 인증 상태
  • 읽기·미리보기·내보내기·변경 승인 같은 action
  • 신뢰 프록시를 거쳐 확정한 source IP
  • request/correlation ID
  • 비밀번호와 URL을 제외한 대상 DB alias
  • SQL 원문 대신 query hash, 길이, statement type
  • 정책 허용·거부와 적용된 제한
  • 성공·거부·오류·timeout·취소 상태
  • 처리 시간, 반환 행·바이트, 잘림 여부

SQL literal에는 개인정보, 토큰, 이메일, 전화번호가 들어갈 수 있습니다. 원문 SQL과 결과 payload를 일반 애플리케이션 로그에 남기지 않습니다. 조사 목적상 원문 보관이 꼭 필요하다면 접근 권한·암호화·보존 기간·삭제 정책을 별도로 둡니다. OTP, 세션 쿠키, Authorization header, DB 자격증명은 어떤 경우에도 감사 이벤트에 복사하지 않습니다.

감사 로그는 애플리케이션과 같은 계정이 마음대로 수정할 수 없는 저장소로 전달하는 편이 좋습니다. 인증 거부 이벤트와 실제 쿼리 생명주기 이벤트를 연결하되, 모든 허용 요청을 무차별 기록해 로그 시스템을 마비시키지 않도록 정책을 정합니다.

운영 전에 일부러 실패시켜 본다

정상 조회 한 번보다 아래 실패 시나리오가 더 많은 사실을 알려줍니다.

  • 권한 없는 사용자가 URL을 직접 호출한다.
  • 외부 요청이 사내 IP를 넣은 X-Forwarded-For를 보낸다.
  • 동일한 OTP와 실행권을 두 번 사용한다.
  • 만료된 실행권을 제출한다.
  • 주석, CTE, 여러 문장이 섞인 SQL을 보낸다.
  • 허용하지 않은 스키마와 함수를 조회한다.
  • 1,001번째 화면 행과 Excel 상한 초과를 확인한다.
  • 느린 쿼리, lock 대기, DB 연결 종료를 만든다.
  • 브라우저를 닫아 Excel 다운로드를 중단한다.
  • 임시 디스크가 부족할 때 안전하게 실패하는지 본다.
  • 같은 사용자가 Ctrl+Enter를 연속 입력한다.
  • 늦게 끝난 이전 응답이 최신 결과를 덮지 않는지 확인한다.
  • 긴 문자열, NULL, 중복 컬럼 label, 수백 개 컬럼을 표시한다.
  • 모바일에서 표가 페이지 전체 폭을 밀어내지 않는지 확인한다.
  • DB가 이미 KST인 값과 UTC instant를 각각 검증한다.

각 테스트에서 HTTP 상태 코드, 사용자 메시지, DB 취소 여부, 감사 이벤트가 함께 맞아야 통과입니다. 화면에 오류가 떴다는 사실만으로 서버 쿼리가 멈췄다고 판단하면 안 됩니다.

성공한 것·실패한 것·최종 체크리스트

성공한 부분

  • 일반 CRUD 요구에서 실제 운영 SQL 콘솔이라는 목표를 다시 정의했습니다.
  • 결과 스키마가 매번 달라지는 문제를 동적 컬럼 그리드로 해결했습니다.
  • 긴 값의 말줄임, 전체 보기, 원본 복사, 고정 헤더와 스크롤을 분리했습니다.
  • 브라우저 Excel 생성 대신 Spring Boot 서버 export로 책임을 옮겼습니다.
  • SXSSFWorkbook(100)이 행 제한이 아니라 메모리 윈도라는 점을 확인했습니다.
  • 시간대 문제를 단순 문자열 보정이 아니라 데이터 의미 문제로 다시 봤습니다.

실패했지만 남겨야 할 부분

  • startsWith("select")와 금지어 목록을 보안 정책처럼 취급했습니다.
  • 전달 헤더를 곧바로 신뢰해 내부망을 판정하려 했습니다.
  • 세션 인증 API에서 CSRF를 습관적으로 끄려 했습니다.
  • OTP 입력 UI와 실제 step-up 인증을 혼동했습니다.
  • event.target에 클래스를 붙여 자식 요소 클릭에서 펼침이 깨졌습니다.
  • Monaco wrapper의 패키지·export·event API를 버전 확인 없이 추측했습니다.
  • SXSSF만 적용하면 DB부터 파일까지 스트리밍된다고 생각했습니다.
  • 모든 LocalDateTime이 UTC일 것이라고 가정해 9시간을 더하려 했습니다.

이 실패들은 버릴 코드가 아니라 다음 설계 판단의 근거가 됐습니다. 운영 도구는 한 번에 완성되기보다, 잘못된 가정을 하나씩 제거하면서 단단해집니다.

배포 전 최종 체크리스트

  • [ ] 콘솔 전용 SELECT-only DB 계정을 사용한다.
  • [ ] 앱의 SUPER_ADMIN을 DB superuser와 혼동하지 않는다.
  • [ ] 읽기와 변경 endpoint·data source·pool을 분리한다.
  • [ ] 변경 기능은 기본 비활성화되고 별도 승인·복구 절차가 있다.
  • [ ] 방화벽·VPN·프록시에서 애플리케이션 직접 접근을 막는다.
  • [ ] 신뢰 프록시가 전달 헤더를 제거하고 다시 쓴다.
  • [ ] 세션·쿠키 인증이면 CSRF token 흐름을 유지한다.
  • [ ] OTP는 서버에서 검증하고 만료·재사용·실패 횟수를 통제한다.
  • [ ] 화면·Excel 각각 max rows, timeout, bytes, concurrency 제한이 있다.
  • [ ] SQL parser뿐 아니라 DB 권한으로 쓰기를 거절한다.
  • [ ] 오류 응답은 상세 SQL·스키마 정보를 숨기고 request ID만 제공한다.
  • [ ] 감사 로그에 OTP·secret·결과 payload를 남기지 않는다.
  • [ ] JDBC 드라이버별 cursor streaming 조건을 실제로 검증한다.
  • [ ] SXSSF 임시 디스크와 dispose()를 모니터링·테스트한다.
  • [ ] DB 시간 의미를 확정하고 UTC 저장·KST 표현 원칙을 문서화한다.
  • [ ] 중복 실행, 취소, stale response, client disconnect를 테스트한다.
  • [ ] 한 번에 콘솔을 끌 수 있는 kill switch와 담당자가 있다.

FAQ

내부망에서만 쓰는데 OTP까지 필요한가?

내부망은 계정 탈취와 내부 단말 감염을 막지 못합니다. 운영 DB처럼 영향이 큰 기능은 SSO와 역할 검사를 기본으로 두고, Excel 대량 반출이나 변경 작업에는 step-up 인증을 추가할 가치가 큽니다. 다만 OTP 입력창만 추가하는 것으로 끝내지 말고 서버 검증·재사용 방지·속도 제한까지 구현해야 합니다.

SELECT만 허용하면 안전한가?

쓰기 위험은 크게 줄지만 데이터 유출, 과부하, lock, 부수효과 함수 같은 위험이 남습니다. 허용 스키마·뷰, 민감 컬럼 마스킹, 최대 행·바이트, timeout, 동시성 제한, 읽기 replica를 함께 검토해야 합니다.

SQL 파서를 쓰면 읽기 전용 DB 계정이 없어도 되나?

아닙니다. 파서는 단일 문장과 허용 구문을 분류하는 보조 방어입니다. DB 방언과 확장 함수, 파서 결함이 있을 수 있으므로 최종 쓰기 거절은 DB 권한이 해야 합니다.

@Transactional(readOnly = true)면 DELETE가 막히나?

항상 그렇다고 보장할 수 없습니다. 드라이버 hint 또는 최적화 정보로만 동작하는 조합이 있습니다. DB가 실제 read-only transaction을 강제하도록 설정하고, SELECT-only 계정을 별도로 사용하세요.

SXSSFWorkbook(100)이면 10만 행도 안전한가?

100은 행 제한이 아니라 메모리 윈도입니다. queryForList()로 전체 결과를 먼저 가져오면 여전히 메모리를 많이 씁니다. JDBC cursor 조건, 행 단위 작성, timeout, export 상한, 임시 디스크와 동시 실행 수를 함께 검증해야 합니다.

KST는 프런트에서만 변환하면 되나?

표시만 해결할 수는 있지만 원본 시간의 의미가 잘못됐다면 오차를 숨길 뿐입니다. DB type과 저장 규칙, API offset 계약을 먼저 정하고, 감사 시각은 UTC instant로 저장한 뒤 표시 경계에서 KST로 바꾸는 편이 좋습니다.

Monaco Editor는 꼭 필요한가?

아닙니다. 구문 강조와 자동 완성이 운영 편의를 높이지만 핵심은 서버 정책입니다. textarea로 먼저 실행·취소·권한·결과 계약을 안정화한 뒤, 실제 설치 버전 문서에 맞춰 점진적으로 추가하는 편이 안전합니다.

기존 글과 함께 보기

프런트엔드 상태와 서버 보안 경계를 더 자세히 보고 싶다면 첫 번째 글을, 동적 컬럼 조회 결과를 애플리케이션으로 전달하는 구조가 필요하다면 두 번째 글을 함께 보면 흐름이 이어집니다.

공식 문서

마치며

이번 작업에서 가장 빨리 완성된 것은 화면이었습니다. textarea에 SQL을 넣고, Axios로 보내고, 첫 행의 key를 헤더로 그리면 금방 “DB 관리 도구 같은 모습”이 나왔습니다. 가장 오래 걸린 것은 그 화면이 무엇을 믿어도 되는지 정하는 일이었습니다.

내부망은 사용자 신원이 아니고, SUPER_ADMIN은 DB superuser가 아니며, OTP 입력칸은 인증이 아닙니다. readOnlyfetchSize도 단독 보장이 아니고, KST는 무조건 9시간을 더하는 규칙이 아닙니다. 각각의 도구가 실제로 보장하는 범위를 정확히 알아야 방어선을 겹칠 수 있습니다.

운영 SQL 콘솔의 완성 기준은 “쿼리가 실행됐다”가 아닙니다. 잘못된 사용자는 거절되고, 허용된 사용자도 정해진 범위만 읽으며, 느린 쿼리는 끝나고, 대량 결과는 자원을 고갈시키지 않고, 모든 결정은 비밀을 남기지 않은 채 추적되는 상태가 진짜 완료입니다.

태그

mLog

8년 이상 풀스택 개발자로 일하고 있습니다. Spring Boot, PostgreSQL, Redis, Vue·TypeScript와 Docker·Linux 서버를 다루며, 직접 운영하고 해결한 내용을 공식 문서와 실행 결과를 바탕으로 정리합니다.