아니, 요즘도 SQL 인젝션이 있네: 실서비스 공격 대응기

내가 직접 개발하고 운영하는 웹 서비스에서 겪은 공격 대응 기록이다. 서비스 이름과 업종, 도메인, 저장소, 고객 정보처럼 신원을 추정할 수 있는 내용은 생략하거나 일반화했다. 공격 문자열도 패턴을 알아보는 데 필요한 형태만 남기고 축약했다.

관리 화면에서 평소와 다른 데이터가 눈에 들어왔다.

사용자가 이름을 입력해야 하는 칸에 정상적인 이름과 함께 이런 문자열이 연달아 저장돼 있었다.

CASE WHEN (...) THEN ... ELSE ... END
SELECT ... FROM DUAL UNION SELECT ...
JSON(CHAR(...))
' OR '1'='1' --

한두 건이 아니었다. 같은 연락처를 사용한 요청이 짧은 시간 동안 반복됐고, 데이터베이스 종류를 가리지 않고 반응을 확인하려는 문법이 섞여 있었다. Oracle, MySQL 등 여러 환경을 차례로 찔러보는 자동화 도구의 전형적인 탐색 패턴에 가까웠다.

화면을 보자마자 든 생각은 솔직히 이것이었다.

“아니, 요즘도 SQL 인젝션이 있네.”

SQL 인젝션은 워낙 오래된 공격이라 교과서에서나 보는 문제처럼 느껴질 때가 있다. ORM과 SDK를 쓰고 서버리스 환경에 배포한 뒤 관리형 데이터베이스를 연결하면 자연스럽게 안전할 것 같기도 하다. 하지만 공격자는 서비스의 기술 수준부터 평가하지 않는다. 입력할 수 있는 폼이 보이면 일단 찔러본다. 오래된 공격이 사라진 게 아니라 자동화되면서 더 싸고 흔해졌다.

이번 탐색 공격의 대상은 연습용 프로젝트가 아니었다. 내가 직접 개발했고 지금도 실제 사용자가 쓰는 운영 서비스였다. 화면, 서버 로직, 데이터베이스, 배포 환경을 모두 관리하고 있었기에 발견부터 차단, 코드 수정, 데이터 정리, 운영 검증까지 직접 해야 했다.

먼저 확인할 것: 공격 문자열이 저장됐다고 DB가 뚫린 것은 아니다

SQL처럼 생긴 문자열이 데이터베이스에 들어갔다고 해서 곧바로 SQL 인젝션에 성공한 것은 아니다.

둘은 구분해야 한다.

  1. 공격자가 SQL 문법을 입력했다.
  2. 그 문자열이 데이터베이스에 일반 문자열로 저장됐다.
  3. 애플리케이션이 그 값을 SQL 구문의 일부로 실행했다.

실제 SQL 인젝션은 세 번째 단계에서 발생한다. 예를 들어 서버가 입력값을 문자열로 이어 붙여 raw SQL을 만든다면 위험하다.

// 하면 안 되는 예
const query = `
  INSERT INTO requests (name, phone)
  VALUES ('${name}', '${phone}')
`

반면 파라미터 바인딩을 사용하는 SDK나 안전하게 구현된 RPC를 거쳤다면 공격 문장은 실행 코드가 아니라 데이터로 저장될 가능성이 높다.

// 개념적인 안전한 예
await database
  .from("requests")
  .insert({ name, phone })

내 서비스도 관리형 데이터베이스 SDK를 사용하고 있었다. 조사 결과, 공격 문자열이 SQL로 실행됐다는 증거는 찾지 못했다. 데이터 유출이나 임의 변경 정황도 확인되지 않았다.

하지만 이것으로 안심할 수는 없었다.

  • 공격 문자열이 서버 입력 검증을 통과했다.
  • 비정상 요청이 짧은 시간 동안 대량 저장됐다.
  • 운영 관리 화면에 악성 문자열이 그대로 노출됐다.
  • 폼에서 데이터베이스까지 이어지는 경계가 충분히 단단하지 않았다.
  • 관리자용 API의 인증 범위도 함께 점검할 필요가 있었다.

이번 사건은 “DB가 뚫렸다”가 아니라 공개 입력 경계가 공격자에게 너무 관대했다는 문제였다.

공격 범위를 확인했다

눈에 띈 몇 건만 보고 판단하지 않았다. 추측 대신 운영 데이터를 기준으로 범위를 확인했다.

확인 결과 악성 패턴이 포함된 요청은 총 165건이었다. 반복 요청에는 다음과 같은 특징이 있었다.

  • 동일한 연락처가 반복 사용됐다.
  • 이름처럼 짧아야 하는 필드에 긴 문자열이 들어갔다.
  • 조건문, 따옴표, 주석, UNION SELECT 계열 문법이 섞여 있었다.
  • 여러 데이터베이스에서 통할 법한 문법을 바꿔가며 시도했다.
  • 정상적인 서비스 이용 목적과 관계없는 자동화 탐색이었다.

여기서 원칙을 하나 세웠다. 공격 데이터를 먼저 지우고 끝내지 않는다.

삭제부터 하면 공격 규모와 패턴을 확인할 근거가 사라진다. 우선 어떤 입력 경로로 들어왔는지, 몇 건인지, 실제 SQL 실행 가능성이 있었는지, 다른 API까지 노출됐는지를 조사했다. 필요한 사실을 확인한 뒤에야 악성 레코드를 제거했다.

민감한 운영 데이터는 로컬에 오래 남기지 않았다. 조사 과정에서 만든 임시 로그와 백업은 검증이 끝난 뒤 삭제했다. 공격 대응 자료가 새로운 개인정보 유출 경로가 되어서는 안 되기 때문이다.

첫 번째 방어: 서버 입력 검증

클라이언트 폼에는 이미 입력 제한이 있었다. 하지만 브라우저의 maxlength, 입력 타입, JavaScript 검증은 보안 경계가 아니다. 공격자는 화면을 거치지 않고 API를 직접 호출할 수 있다.

실제 저장 직전에 서버가 입력을 다시 검사하도록 바꿨다.

검증 대상은 대략 다음과 같았다.

  • 필수 필드 존재 여부
  • 문자열 타입 여부
  • 앞뒤 공백 정리
  • 이름과 제목의 최대 길이
  • 전화번호 형식과 정규화
  • 허용되지 않은 제어 문자
  • 선택값이 서버의 허용 목록에 포함되는지
  • 예상하지 못한 추가 필드 처리

개념적으로는 다음과 같은 구조다.

function validateSubmission(input: unknown) {
  if (!input || typeof input !== "object") {
    throw new Error("Invalid request")
  }

  const name = normalizeText(input.name)
  const phone = normalizePhone(input.phone)

  if (name.length < 2 || name.length > MAX_NAME_LENGTH) {
    throw new Error("Invalid name")
  }

  if (!PHONE_PATTERN.test(phone)) {
    throw new Error("Invalid phone")
  }

  return { name, phone }
}

여기서 특정 SQL 키워드를 블랙리스트로 막는 방식은 사용하지 않았다.

// 좋은 방어가 아니다
if (name.includes("SELECT") || name.includes("UNION")) {
  throw new Error("Blocked")
}

이런 필터는 쉽게 우회된다. 정상 입력에도 해당 단어가 포함될 수 있고, 대소문자·주석·인코딩·문자 분할로 변형할 방법도 많다. SQL 인젝션 방어의 중심은 입력값을 SQL 코드와 분리하는 것이다. 길이와 형식 검증은 비정상 데이터를 줄이는 보조 방어이고, 실제 쿼리는 파라미터 바인딩이나 제한된 RPC를 사용해야 한다.

두 번째 방어: 관리자 API 인증

공개 폼만 보고 끝내지 않고 관리자 API도 전부 다시 조사했다. 이 과정에서 일부 데이터 조회 경로의 인증 경계가 기대보다 약하다는 사실을 확인했다.

운영 서비스에서는 입력 공격과 데이터 노출을 따로 생각하면 안 된다. 공격자가 폼으로 악성 데이터를 넣는 것보다, 인증 없이 관리자 데이터를 읽을 수 있는 상황이 훨씬 심각할 수 있다.

조치 후 관리자용 API는 서버에서 세션을 확인한 다음에만 데이터를 반환하도록 통일했다.

export async function GET(request: Request) {
  const session = await requireAdminSession(request)

  if (!session) {
    return new Response("Unauthorized", { status: 401 })
  }

  return loadProtectedData()
}

배포 후에는 코드만 보고 끝내지 않았다. 실제 운영 주소에 인증 없이 요청을 보내 401 Unauthorized가 반환되는지 확인했다. 보안 변경은 “구현했다”보다 운영 경계에서 정말 거부되는가가 중요하다.

세 번째 방어: Cloudflare Turnstile을 서버에서 검증

입력 검증만으로는 자동화 요청의 속도를 낮추기 어렵다. 그래서 공개 요청 폼에 Cloudflare Turnstile을 추가했다.

Turnstile을 붙이면서 가장 신경 쓴 부분은 클라이언트의 성공 표시를 믿지 않는 것이었다. 브라우저에서 체크 표시가 나왔다고 해서 서버가 요청을 받아들이면 안 된다.

처리 흐름은 이렇게 구성했다.

사용자 입력
  → Turnstile 토큰 발급
  → 서버로 입력값과 토큰 전달
  → 서버가 Cloudflare Siteverify 호출
  → 검증 성공 시에만 데이터베이스 작업

서버에서는 단순히 success만 보지 않았다.

  • HTTP 응답이 정상인지
  • JSON 응답을 정상적으로 읽었는지
  • successtrue인지
  • 토큰을 발급한 hostname이 허용 목록과 일치하는지
  • 위젯에 지정한 action이 서버의 예상값과 같은지
  • 만료되거나 재사용된 토큰이 아닌지
const response = await fetch(
  "https://challenges.cloudflare.com/turnstile/v0/siteverify",
  {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      secret: process.env.TURNSTILE_SECRET_KEY!,
      response: token,
    }),
  },
)

if (!response.ok) {
  throw new Error("Verification unavailable")
}

const result = await response.json()

if (
  result.success !== true ||
  !ALLOWED_HOSTNAMES.has(result.hostname) ||
  result.action !== EXPECTED_ACTION
) {
  throw new Error("Verification failed")
}

Cloudflare 응답이 실패하거나 네트워크 오류가 나면 요청을 허용하지 않는 fail closed 방식으로 처리했다. 외부 검증 서버가 잠시 불안정할 때 요청을 통과시키면 편하겠지만, 검증 장애가 그대로 우회 경로가 된다.

또한 Turnstile 토큰은 제출을 시작하는 순간 클라이언트 상태에서 소비하도록 했다. 실패 후 같은 토큰을 다시 사용하는 흐름을 막고, 만료·오류 시 위젯을 재설정했다.

폼이 하나가 아니었다

처음에는 대표 폼 몇 곳만 고치면 될 줄 알았다. 코드를 전체 검색해보니 같은 서버 작업을 호출하는 폼이 9개였다.

랜딩 페이지, 상세 페이지, 플로팅 UI, 별도 문의 화면처럼 모양과 위치는 달랐지만 최종적으로 같은 데이터 저장 경로를 사용했다. 눈에 보이는 대표 화면만 고쳤다면 나머지 폼이 우회 통로로 남았을 것이다.

공용 Turnstile 컴포넌트를 만들고 모든 호출 지점을 확인했다. 각 폼에서 다음 상태를 일관되게 처리했다.

  • 토큰 발급 전 제출 금지
  • 토큰 만료 처리
  • 위젯 오류 처리
  • 제출 시작 시 토큰 소비
  • 실패 후 재검증
  • 중복 제출 방지

보안 기능을 적용할 때 세어야 하는 것은 화면이 아니라 데이터가 들어오는 모든 경로였다.

네 번째 방어: 애플리케이션이 아니라 DB에서 중복 요청을 직렬화

Turnstile은 봇을 줄여주지만 모든 자동화를 막는 절대적인 방어 수단은 아니다. 사람이 직접 토큰을 풀 수도 있고, 정상 브라우저를 자동화할 수도 있다.

그래서 동일한 정규화 연락처로 일정 시간 안에 반복되는 요청은 데이터베이스에 다시 저장하지 않도록 했다. 적용한 제한 시간은 10분이었다.

가장 단순한 방법은 최근 데이터를 먼저 조회하고, 없으면 저장하는 것이다.

const exists = await hasRecentSubmission(phone)

if (!exists) {
  await insertSubmission(data)
}

하지만 이 방식에는 경쟁 조건이 있다.

두 요청이 거의 동시에 들어오면 둘 다 exists = false를 확인한 뒤 나란히 저장할 수 있다. 부하 테스트나 자동화 공격처럼 요청 간격이 짧을 때 실제로 발생할 수 있는 문제다.

그래서 PostgreSQL RPC 안에서 transaction advisory lock과 조회·저장을 하나로 묶었다.

PERFORM pg_advisory_xact_lock(
  hashtextextended(p_normalized_phone, 0)
);

IF EXISTS (
  SELECT 1
  FROM public.requests
  WHERE normalized_phone = p_normalized_phone
    AND created_at >= now() - interval '10 minutes'
) THEN
  RETURN QUERY SELECT false;
  RETURN;
END IF;

INSERT INTO public.requests (...)
VALUES (...);

RETURN QUERY SELECT true;

동일한 연락처를 사용하는 요청만 같은 잠금을 놓고 순서대로 처리된다. 서로 다른 정상 사용자의 요청은 불필요하게 막지 않는다.

IP 주소만으로 강하게 제한하지 않은 데도 이유가 있다. 이동통신사, 회사, 학교, 공용 네트워크에서는 여러 사람이 하나의 공인 IP를 공유할 수 있다. IP를 유일한 기준으로 삼으면 정상 사용자를 함께 차단할 가능성이 높다. IP는 로그와 보조 신호로 활용하되, 서비스 흐름에 맞는 정규화 식별값을 중복 제한의 중심으로 사용했다.

중복 요청이 들어왔을 때 사용자에게는 기존과 같은 완료 화면을 보여줬다. “이미 제출했다”는 구체적인 응답은 특정 연락처의 접수 여부를 외부에 알려주는 정보가 될 수 있고, 봇에게 제한 규칙을 친절하게 설명하는 결과도 낳는다. 데이터베이스에는 다시 저장하지 않되 외부 응답은 일반화했다.

다섯 번째 방어: RLS만 켜고 안심하지 않기

관리형 데이터베이스를 쓰면 브라우저용 공개 키와 Row Level Security(RLS)를 함께 사용하는 경우가 많다. 여기서 흔히 생기는 오해가 있다.

RLS를 활성화했다고 모든 접근이 자동으로 안전해지는 것은 아니다.

정책, 테이블 권한, 함수 실행 권한을 함께 봐야 한다. 특히 SECURITY DEFINER 함수나 RPC가 있다면 누가 실행할 수 있는지 명시적으로 확인해야 한다.

이번에는 다음과 같이 권한을 정리했다.

  • 대상 테이블의 RLS 활성화
  • 브라우저 역할의 직접 테이블 권한 회수
  • 공개·일반 인증 역할의 RPC 실행 권한 회수
  • 서버 전용 역할에만 RPC 실행 권한 부여
  • RPC의 search_path 고정
  • 중복 조회에 필요한 인덱스 추가

개념적인 권한 정책은 다음과 같다.

ALTER TABLE public.requests ENABLE ROW LEVEL SECURITY;

REVOKE ALL ON TABLE public.requests
FROM anon, authenticated;

REVOKE ALL ON FUNCTION public.submit_request(...)
FROM PUBLIC, anon, authenticated;

GRANT EXECUTE ON FUNCTION public.submit_request(...)
TO service_role;

적용 후에는 실제 권한을 다시 조회했다.

anonymous role: RPC 실행 불가
authenticated role: RPC 실행 불가
server role: RPC 실행 가능

애플리케이션 코드가 안전해도 공개 키로 데이터베이스나 RPC를 직접 호출할 수 있으면 서버 검증을 우회할 수 있다. 서버에서 Turnstile과 입력값을 검사해놓고 브라우저가 DB에 직접 쓸 수 있게 두면 앞단의 방어가 아무 의미가 없다.

운영 DB 변경 전 실제 스키마부터 읽었다

처음 만든 마이그레이션을 곧바로 운영 DB에 실행하지 않았다. 로컬 코드가 기대하는 구조와 운영 데이터베이스의 실제 구조가 다를 수 있기 때문이다.

CLI로 읽기 전용 메타데이터를 조회해 다음 항목을 확인했다.

  • 컬럼과 타입
  • 제약조건
  • 인덱스
  • RLS 활성 상태
  • 정책
  • 역할별 권한
  • 함수와 실행 권한
  • 트리거

개인정보 행은 조회하지 않았다. 구조를 확인하는 데 실제 고객 데이터는 필요하지 않았다.

조사 결과에 맞춰 마이그레이션을 보완한 뒤 운영 DB에 적용했다. 적용 후 같은 메타데이터 조회를 다시 실행해 RLS, 권한, 함수, 인덱스가 의도한 상태인지 검증했다.

이 작업에는 GUI SQL Editor보다 CLI가 훨씬 안정적이었다. 실행한 쿼리와 종료 상태를 남길 수 있고, 읽기 전용 조사와 변경 작업을 분리하기도 쉬웠다.

테스트와 독립 보안 리뷰

보안 코드는 작성자의 의도대로만 읽으면 놓치는 부분이 생긴다. 별도 리뷰에서 실제로 세 가지 문제가 발견됐다.

  1. Siteverify가 비정상 HTTP 상태를 반환할 때 명확하게 차단해야 했다.
  2. 제출을 시작할 때 Turnstile 토큰을 즉시 소비해야 했다.
  3. 운영 스키마와 마이그레이션의 컬럼 구성이 정확히 맞는지 보완이 필요했다.

첫 리뷰는 승인되지 않았다. 지적된 내용을 수정하고 회귀 테스트를 추가한 뒤 다시 검토해 최종 승인을 받았다.

검증 범위는 다음과 같았다.

  • 보안 관련 자동 테스트 11개
  • TypeScript 타입 검사
  • 변경 파일 ESLint
  • Production build
  • 정적 페이지 63개 생성 확인
  • git diff --check
  • 운영 배포 상태
  • 주요 운영 URL의 HTTPS 200 응답
  • 무인증 관리자 API의 401 응답
  • 일반 브라우저에서 Turnstile과 실제 제출 동작
  • DB 역할별 RPC 실행 권한

전체 lint에서는 이번 변경과 관계없는 기존 파일의 오류가 남아 있었다. 이를 보안 작업이 모두 통과한 것처럼 숨기지 않고, 변경 파일 검사 결과와 기존 오류를 분리해 기록했다.

악성 데이터 165건을 정리했다

방어 코드와 운영 권한을 적용한 뒤 공격 데이터 165건을 삭제했다. 삭제 후 같은 조건으로 다시 조회해 잔존 데이터가 0건인지 확인했다.

함께 정리한 것은 다음과 같다.

  • 조사용 임시 로그
  • 로컬에 만든 공격 데이터 백업
  • 임시 스크립트
  • 개인정보가 들어갈 수 있는 중간 파일
  • 작업용 브랜치와 임시 작업 공간

소스 코드와 테스트, 재사용 가능한 마이그레이션은 남겼지만 운영 개인정보와 공격 원문을 불필요하게 보존하지 않았다.

최종 방어 구조

최종 요청 흐름은 다음과 같다.

공개 폼
  ↓
클라이언트 기본 검증
  ↓
Turnstile 토큰 발급
  ↓
서버 입력값 정규화·검증
  ↓
서버가 Cloudflare Siteverify 호출
  ├─ 실패: DB 클라이언트 생성 전 거부
  └─ 성공
       ↓
서버 전용 역할로 제한된 PostgreSQL RPC 호출
       ↓
동일 연락처 advisory lock
       ↓
최근 10분 중복 확인
  ├─ 중복: INSERT 없이 일반 완료 응답
  └─ 신규: INSERT

데이터베이스 쪽 경계는 별도로 닫았다.

브라우저 공개 역할
  ├─ 테이블 직접 접근 불가
  └─ 제출 RPC 실행 불가

서버 전용 역할
  └─ 검증 완료 후 제출 RPC 실행 가능

어느 한 기능도 단독으로 완전한 해결책은 아니다.

  • 입력 검증은 비정상 데이터를 줄이지만 봇 자체를 막지는 못한다.
  • Turnstile은 자동화를 줄이지만 우회 가능성을 0으로 만들지는 못한다.
  • 중복 제한은 대량 저장을 막지만 잘못 구현하면 경쟁 조건이 생긴다.
  • RLS는 중요하지만 테이블과 함수 권한이 열려 있으면 충분하지 않다.
  • 관리자 API 인증은 데이터 노출을 막지만 공개 폼의 남용까지 해결하지 않는다.

각 방어가 다른 방어의 실패를 보완하도록 겹쳐 놓는 것이 중요했다.

이번 일에서 배운 것

오래된 공격은 사라지지 않는다

SQL 인젝션은 낡은 공격이라기보다 비용이 거의 들지 않는 기본 탐색 항목에 가깝다. 공개 폼이 있으면 공격자는 서비스가 어떤 스택으로 만들어졌는지 모르는 상태에서도 일단 문자열을 던진다.

“요즘 누가 이런 걸 하지?”가 아니라 “자동화 도구라면 당연히 이것부터 넣겠지”라고 생각해야 한다.

SDK를 쓴다는 사실과 안전하다는 결론은 다르다

관리형 SDK와 파라미터 바인딩 덕분에 실제 SQL 실행은 피했을 가능성이 높았다. 그래도 비정상 데이터 165건이 운영 DB에 들어왔고 관리자 화면까지 도달했다. 쿼리 실행 안전성과 운영 서비스의 전체 보안은 같은 문제가 아니다.

브라우저 검증은 UX다

클라이언트 검증은 정상 사용자가 실수했을 때 빠르게 알려주는 장치다. 보안 판단은 서버에서 다시 해야 한다. 브라우저를 건너뛴 요청에도 같은 규칙이 적용돼야 한다.

공개 쓰기 경로는 끝까지 추적해야 한다

폼에 Turnstile을 붙이는 것만으로는 부족하다. 서버 작업, RPC, 테이블 권한까지 따라가야 한다. 공격자가 앞단을 우회해 DB를 직접 호출할 수 있다면 위젯은 장식에 불과하다.

배포 성공과 보안 검증 완료는 다르다

빌드가 통과하고 배포 상태가 Ready여도 실제 운영 API가 원하는 상태로 거부하는지는 별도 문제다. 무인증 요청의 401, 일반 브라우저의 실제 제출, 역할별 DB 권한을 각각 확인해야 했다.

실제 운영은 코드만 고치는 일이 아니다

이번 대응에는 코드 수정 외에도 공격 범위 조사, 운영 데이터 정리, 비밀키 교체, 환경변수 분리, DB 마이그레이션, 권한 검증, 배포 확인, 임시 개인정보 파일 삭제가 포함됐다.

개발 환경에서 테스트가 통과한 시점은 중간 단계였다. 실제 사용자가 쓰는 서비스에서 방어가 작동하고, 잘못된 접근이 거부되며, 정상 요청은 계속 처리되는 것을 확인해야 작업이 끝난다.

마치며

처음 화면에서 SQL 문법이 가득한 데이터를 봤을 때는 “아니, 요즘도 SQL 인젝션이 있네”라는 생각부터 들었다. 조사하고 나니 질문을 조금 바꿔야 했다.

SQL 인젝션이 아직도 있느냐가 문제가 아니었다. 공개 입력창이 있는 한 이런 탐색은 계속 들어온다. 중요한 것은 공격 문자열 하나를 필터링하는 일이 아니라, 입력부터 데이터베이스까지 어느 경계가 실패해도 다음 경계가 막아주는 구조를 갖추는 것이다.

이번 공격에서 실제 SQL 실행이나 데이터 유출 증거는 발견되지 않았다. 그렇다고 아무 일도 아니었던 것은 아니다. 서버 입력 검증이 약했고, 자동화 요청이 운영 DB에 쌓였으며, 관리자 API와 데이터베이스 권한까지 다시 점검해야 했다.

대응을 마친 뒤 서비스의 경계는 이전보다 훨씬 단단해졌다.

  • 비정상 입력은 서버에서 거부된다.
  • 자동화 요청은 Turnstile로 한 번 더 걸러진다.
  • 검증 장애는 우회가 아니라 차단으로 처리된다.
  • 동시 반복 요청은 데이터베이스 트랜잭션 안에서 제한된다.
  • 브라우저 역할은 테이블과 제출 RPC에 직접 접근할 수 없다.
  • 관리자 데이터는 인증 없이 조회할 수 없다.
  • 운영 배포 뒤 실제 거부와 정상 제출을 모두 확인한다.

실서비스를 직접 개발하고 운영하는 일은 기능을 만드는 데서 끝나지 않는다. 공개 입력창 하나가 어디까지 이어지는지 알아야 하고, 문제가 생기면 그 경로 전체를 닫을 수 있어야 한다. 이번에는 그 사실을 운영 데이터 165건으로 다시 배웠다.

Posts