본문 바로가기

IT정리/보안 기초

2-2. 비즈니스 로직 보안 - 매크로를 막기 위한 추가 로직 보안

매크로/봇은 더 이상 브라우저 자동화 수준에 머물지 않는다.

단순 키보드/마우스 매크로를 넘어 HTTP 요청을 직접 생성하고 세션/쿠키/CSRF 토큰까지 그대로 재사용하면서 서버를 두드린다.

따라서 UI에만 의존한 방어(캡차, disabled 버튼, JS 검증)는 조금만 시간이 지나면 우회된다.

진짜 관건은 “서버가 스스로 요청의 정당성을 판단할 수 있느냐?”이다.

 

1. 토큰 + 맥락 바인딩(Context Binding)의 필요성

1.1. 토큰은 원래 무엇을 위한 것인가

  • CSRF 토큰
  • 액션 토큰(action_token)
  • 이메일 인증 토큰
  • 비밀번호 찾기 토큰

이 토큰들의 공통점은 랜덤 문자열 == 세션 값으로 검증하는 것이다.

1.2. 맥락(Context)란 무엇인가

토큰이 어떤 상황에서 발급되었는지에 대한 정보를 예약/강습 시스템 기준으로 맥락의 예를 들면:

  • 사용자 정보
    • member_no, login_id, 휴대폰 번호 등
  • 대상 자원
    • lecture_id, place_cd, time_cd, use_date 등
  • 시간 정보
    • 발급 시각(issued_at)
    • 만료 시각(expires_at)
  • 동작 종류
    • “RESERVE”, “CANCEL”, “PAYMENT” 등
  • 흐름 단계
    • “FORM_VIEWED”, “STEP2_CONFIRMED” 등

식으로 토큰을 만들 수 있다.

결국

  • 이 토큰이 다른 사용자 요청에 쓰일 수 없어야 한다.
  • 이 토큰이 다른 자원(다른 강좌, 다른 날짜)에 쓰일 수 없어야 한다.
  • 만료된 시간 이후에는 쓸 수 없어야 한다.
  • 의도한 동작 종류 외에는 사용할 수 없어야 한다.

를 충족하고 있어야 한다.

1.3. 좋은 설계 예 – 서버 세션에 맥락 구조체를 저장하는 방식

토큰 발급 시

public String getReserveForm(@PathVariable String lectureId,
                              HttpServletRequest request,
                              HttpSession session,
                              Model model,
                              User loginUser) {

    // 1. 클라이언트에서 날짜/시간 파라미터 받기
    String useDateParam = request.getParameter("useDate");
    String timeCd       = request.getParameter("timeCd");
    LocalDate useDate = LocalDate.parse(useDateParam);

    // 2. 토큰 ID 생성 (action_token으로 내려갈 값)
    String tokenId = UUID.randomUUID().toString();

    // 3. 토큰 맥락 정보 Map 구성
    Map<String, Object> tokenContext = new HashMap<>();
    tokenContext.put("memberNo", loginUser.getMemberNo());
    tokenContext.put("lectureId", lectureId);
    tokenContext.put("useDate", useDate);
    tokenContext.put("timeCd", timeCd);
    tokenContext.put("action", "RESERVE");
    tokenContext.put("issuedAt", Instant.now());
    tokenContext.put("expiresAt", Instant.now().plusSeconds(60));

    // 4. 세션에 저장 (key: ACTION_TOKEN_토큰ID)
    String sessionKey = "ACTION_TOKEN_" + tokenId;
    session.setAttribute(sessionKey, tokenContext);

    // 5. JSP로 내려줄 값들
    model.addAttribute("action_token", tokenId);

    return "lecture/reserveForm";
}

화면에 노출

<input type="hidden" name="action_token" value="${action_token}">

검증 시

public void postReserveForm(HttpSession session,
                                 User loginUser,
                                 HttpServletRequest request) {

    // 1. 클라이언트가 보낸 토큰 ID
    String tokenId = request.getParameter("action_token");
    if (tokenId == null || tokenId.isEmpty()) {
        throw new SecurityException("유효하지 않은 요청입니다. (토큰 없음)");
    }

    // 2. 세션에서 토큰 맥락 Map 조회
    String sessionKey = "ACTION_TOKEN_" + tokenId;
    Map<String, Object> ctx = (Map<String, Object>) session.getAttribute(sessionKey);
    if (ctx == null) {
        throw new SecurityException("유효하지 않은 요청입니다. (토큰 컨텍스트 없음)");
    }

    // 3. 만료 시간 검증
    Instant expiresAt = (Instant) ctx.get("expiresAt");
    if (expiresAt != null && expiresAt.isBefore(Instant.now())) {
        session.removeAttribute(sessionKey);  // 만료된 건 정리
        throw new SecurityException("만료된 토큰입니다.");
    }

    // 4. 사용자 바인딩 검증
    String ctxMemberNo = (String) ctx.get("memberNo");
    if (!Objects.equals(ctxMemberNo, loginUser.getMemberNo())) {
        throw new SecurityException("다른 사용자 토큰입니다.");
    }

    // 5. 동작 종류 검증
    String ctxAction = (String) ctx.get("action");
    if (!"RESERVE".equals(ctxAction)) {
        throw new SecurityException("잘못된 용도의 토큰입니다.");
    }

    // 6. 일회성 사용 처리 (재사용 방지)
    session.removeAttribute(sessionKey);
}

이 구조에서는 다음과 같은 재사용이 불가능하다.

  • 다른 회원(member_no)으로 동일 토큰 사용 → 사용자 불일치
  • 다른 강좌/시간대로 요청 → 자원 정보 불일치
  • 유효시간 초과 후 사용 → 만료
  • 다른 동작(취소/변경)에서 사용 → action 불일치

즉, 토큰을 훔쳐가도 맥락이 맞지 않으면 쓸 수 없다.

문자열로 합치고 base64 인코딩 검증 시:

  1. 기준으로 payload와 signature 분리
  2. 서버에서 HMAC_SHA256(payload, secretKey) 재계산 후 signature와 비교 → 위변조 여부 확인
  3. payload base64 decode → 필드 파싱(memNo, lectureId, useDate ...)
  4. 현재 로그인 사용자, 요청 파라미터, 시간과 비교

주의할 점:

  • secretKey는 서버에만 있어야 하며, 외부에 노출되면 안 된다.
  • 토큰을 일회성으로 쓰고 싶다면 별도의 사용 로그 테이블로 관리해야 한다.

 

2. 흐름 검증(Flow Validation)의 필요성

2.1. 왜 절차(Flow)를 검증해야 하는가

  1. 예약 안내 페이지 진입 (GET /lecture/123)
  2. 강좌 정보 확인, 수강 조건 확인
  3. 개인정보/약관 동의 체크
  4. 결제 수단 선택
  5. 예약 요청 전송 (POST /lecture/123/apply)

사람은 이 순서를 자연스럽게 따른다.

처음부터 5번 단계 URL만 안다면 1~4 과정을 모두 생략하고 “직접 POST /lecture/123/apply” 요청만 반복해서 보낼 수 있다.

이때 서버가 “이 요청이 1~4 과정을 거쳐온 요청인지”를 확인하지 않으면

  • 동의 체크를 하지 않았는데도 예약이 가능하고
  • 안내문을 읽지도 않은 상태에서 허용 규칙을 훼손할 수 있고
  • 자동화된 스팸/매크로가 POST만 계속 때려도 막을 수 없다.

즉 매크로의 접근에 취약한 상태가 된다.

2.2. 흐름 검증 패턴 – flow_token + 세션

흐름 검증의 핵심 개념은 다음과 같다.

이 사용자가 이 URL을 통해 어떤 단계를 이미 통과했는지를 서버가 기록하고 다음 단계에서 그 기록을 확인한 후 통과시킨다.

패턴:

  1. 단계 진입 시 flow_token 발급
  2. 세션에 flow_token과 단계 정보를 저장
  3. 다음 요청에서 flow_token을 넘기도록 요구
  4. 세션에 저장된 단계 정보와 비교
  5. 일치하면 다음 단계로 전환, 불일치하면 에러

자세한 예시는 2.3. 마법사(Wizard)형 다단계 흐름에서의 검증의 예시코드를 참고할 수 있다.

2.3. 마법사(Wizard)형 다단계 흐름에서의 검증

보통 STEP1 → STEP2 → STEP3처럼 나뉘어 있다.

  • STEP1: 기본 정보 입력
  • STEP2: 외부 인증 기관 연동
  • STEP3: 결과 확인 후 최종 등록

이때 각 단계에서 세션에 현재 step을 기록하는 방식:이렇게 하면:

  • URL만 안다고 해서 STEP3부터 바로 치고 들어오는 요청은 전부 차단된다.
  • 브라우저에서 뒤로 가기/새로고침을 하더라도 현재 단계가 뒤죽박죽 되는 것을 일부 방지할 수 있다(정교한 설계 필요)

STEP1 GET진입

@GetMapping("/step1")
public String showStep1(HttpSession session,
                        Model model,
                        User loginUser) {

    // 1. flow_token 생성
    String flowToken = UUID.randomUUID().toString();

    // 2. 흐름 상태를 Map으로 구성
    Map<String, Object> flow = new HashMap<>();
    flow.put("memberNo", loginUser.getMemberNo());
    flow.put("step", "STEP1");                    // 현재 단계
    flow.put("issuedAt", Instant.now());
    flow.put("expiresAt", Instant.now().plusSeconds(300));

    // 3. 세션에 저장 (FLOW_TOKEN_ + 토큰)
    session.setAttribute("FLOW_TOKEN_" + flowToken, flow);

    // 4. JSP에 내려줄 값
    model.addAttribute("flow_token", flowToken);

    return "wizard/step1Form";
}

STEP1 JSP

<form method="post" action="/wizard/step1">
    <!-- 흐름 검증용 flow_token (필수) -->
    <input type="hidden" name="flow_token" value="${flow_token}"/>

    <%-- 여기부터는 개발자 재량 (STEP1에서 받을 입력값들) --%>

    <button type="submit">다음 단계로</button>
</form>

STEP1 POST 진입 > STEP2로 이동

@PostMapping("/step1")
public String submitStep1(HttpSession session,
                          HttpServletRequest request,
                          User loginUser) {

    String flowToken = request.getParameter("flow_token");
    if (flowToken == null || flowToken.isEmpty()) {
        throw new SecurityException("flow_token 없음");
    }

    String sessionKey = "FLOW_TOKEN_" + flowToken;
    @SuppressWarnings("unchecked")
    Map<String, Object> flow = (Map<String, Object>) session.getAttribute(sessionKey);
    if (flow == null) {
        throw new SecurityException("유효하지 않은 흐름입니다. (컨텍스트 없음)");
    }

    // [흐름 검증의 핵심]
    // 맥락 바인딩과의 차이점:
    // - 맥락 바인딩: "누구/어떤자원/어떤동작"이 맞는지 검증
    // - 흐름 검증: "현재 단계(step)가 올바른 순서인지" 검증
    String step = (String) flow.get("step");
    if (!Objects.equals(step, "STEP1")) {
        throw new SecurityException("올바른 순서가 아닙니다. (현재 단계: " + step + ")");
    }

    Instant expiresAt = (Instant) flow.get("expiresAt");
    if (expiresAt.isBefore(Instant.now())) {
        session.removeAttribute(sessionKey);
        throw new SecurityException("흐름이 만료되었습니다.");
    }

    // TODO: STEP1에서 받은 폼 데이터가 있다면 flow Map에 저장 가능
    // ex) flow.put("userMemo", request.getParameter("userMemo"));

    // STEP1 → STEP2 로 단계 전이
    flow.put("step", "STEP2");
    session.setAttribute(sessionKey, flow);

    // STEP2 화면으로 이동 (flow_token 전달)
    return "redirect:/wizard/step2?flow_token=" + flowToken;
}

STEP2 JSP

<form method="post" action="/wizard/step2">
    <!-- 흐름 검증용 flow_token (필수) -->
    <input type="hidden" name="flow_token" value="${flow_token}"/>

    <%-- 여기부터는 개발자 재량 (최종 확인용 정보 표시 등) --%>

    <button type="submit">최종 완료</button>
</form>

STEP2 POST 진입 > 최종 완료

@PostMapping("/step2")
public String submitStep2(HttpSession session,
                          HttpServletRequest request,
                          User loginUser) {

    String flowToken = request.getParameter("flow_token");
    if (flowToken == null || flowToken.isEmpty()) {
        throw new SecurityException("flow_token 없음");
    }

    String sessionKey = "FLOW_TOKEN_" + flowToken;
    @SuppressWarnings("unchecked")
    Map<String, Object> flow = (Map<String, Object>) session.getAttribute(sessionKey);
    if (flow == null) {
        throw new SecurityException("유효하지 않은 흐름입니다. (컨텍스트 없음)");
    }

    String step = (String) flow.get("step");
    if (!Objects.equals(step, "STEP2")) {
        throw new SecurityException("STEP2 단계에서만 완료가 가능합니다. (현재 단계: " + step + ")");
    }

    String memberNo = (String) flow.get("memberNo");
    if (!Objects.equals(memberNo, loginUser.getMemberNo())) {
        throw new SecurityException("다른 사용자의 흐름입니다.");
    }

    Instant expiresAt = (Instant) flow.get("expiresAt");
    if (expiresAt.isBefore(Instant.now())) {
        session.removeAttribute(sessionKey);
        throw new SecurityException("흐름이 만료되었습니다.");
    }

    // TODO: 여기에서 실제 비즈니스 로직 수행

    // 흐름 종료
    session.removeAttribute(sessionKey);

    return "redirect:/wizard/complete";
}

특히 flow_token의 장점은 토큰이 JSP에 표출되지 않아서 메크로에 잡히지 않는다는 점이다.

이제 /step2의 URL만 따로 알아낸 매크로가 POST를 보내도

  • flow_token이 없으면 실패
  • 다른 사람의 flow_token이면 실패
  • 이전 단계를 거치지 않으면 flow_token 발급 불가능

브라우저 없이 POST만 보내는 공격이 크게 어려워진다.

 

3. 상태 전이 검증(State Machine Validation)

컬럼: RSVN_STATE에 값이

  • READY: 예약 가능
  • APPLY: 접수(신청만 완료)
  • PAY_WAIT: 결제 대기
  • PAY_DONE: 결제 완료
  • CANCEL: 취소
  • USED: 사용 완료

같은 값들이 들어갈 수 있을 때 비즈니스 정책 상 정상 흐름은

READY → APPLY → PAY_WAIT → PAY_DONE → USED

의 흐름으로 갈 수 있다.

취소는 여러 지점에서 가능한데 예를 들어

APPLY → CANCEL / PAY_WAIT → CANCEL / PAY_DONE → CANCEL

일 때 취소가 가능하다.

문제는 개발자가 쿼리를 이렇게 작성할 때 생긴다.

UPDATE RENT_APP
   SET RSVN_STATE = 'PAY_DONE'
 WHERE RSVN_ID = :id;

이 쿼리는 "현재 상태가 무엇이든 상관없이 PAY_DONE으로 덮어쓴다" 는 의미다.

  • READY였던 예약도 PAY_DONE이 될 수 있고
  • CANCEL된 예약도 PAY_DONE이 될 수 있고
  • USED 상태도 PAY_DONE으로 되돌릴 수 있다.

이제 매크로/공격자는 다음과 같은 짓이 가능해진다.

  • 결제 창을 열지도 않고, 내부에서 쓰는 “/payComplete” 엔드포인트만 호출
  • 심지어 성공 응답을 확인해가면서 여러 번 호출

3.1. 상태 전이 검증의 기본 원칙

상태 전이 검증의 기본 원칙은 간단하게 “현재 상태가 X일 때만 Y로 바꿀 수 있다" 를 검증하는 것을 말한다.

이를 SQL WHERE 조건으로 표현하면:

UPDATE RENT_APP
   SET RSVN_STATE = :next_state
 WHERE RSVN_ID    = :id
   AND RSVN_STATE = :current_state;

그리고 애플리케이션에서는 “영향받은 행 수”를 확인한다.

int updated = jdbcTemplate.update(SQL, params);
if (updated == 0) {
    // 현재 상태가 기대와 다르거나, 다른 사람이 수정한 경우
    throw new BizException("상태 전이 실패");
}

이렇게 하면:

  • 예상한 상태가 아닐 경우 업데이트가 이루어지지 않는다.
  • 결제 페이지를 거치지 않고 바로 PAY_DONE으로 바꾸려 해도 기존 상태가 PAY_WAIT가 아니므로 실패한다.

3-3. Enum + 상태 머신으로 전이 규칙 정의하기

코드 레벨에서 상태 전이 규칙을 명시하는 것도 중요하다.

public enum RsvnState {
    READY,
    APPLY,
    PAY_WAIT,
    PAY_DONE,
    CANCEL,
    USED;

    public boolean canTransitTo(RsvnState next) {
        return switch (this) {
            case READY    -> (next == APPLY || next == CANCEL);
            case APPLY    -> (next == PAY_WAIT || next == CANCEL);
            case PAY_WAIT -> (next == PAY_DONE || next == CANCEL);
            case PAY_DONE -> (next == USED || next == CANCEL);
            case CANCEL   -> false;
            case USED     -> false;
        };
    }
}

public void changeState(RentApp app, RsvnState next) {
    RsvnState current = app.getState();
    if (!current.canTransitTo(next)) {
        log.warn("INVALID_STATE_TRANSITION rsvnId={}, from={}, to={}",
                 app.getId(), current, next);
        throw new BizException("허용되지 않는 상태 전이입니다.");
    }

    app.setState(next);
    rentAppRepository.save(app);
}

이렇게 하면

  • 기획서에 있는 “상태 전이 표”를 코드로 그대로 옮길 수 있다.
  • 새로운 상태를 추가할 때, canTransitTo를 같이 수정하지 않으면 컴파일 에러/로직 에러가 바로 보인다.

3-4. DB 레벨 + 코드 레벨 이중 검증

코드 레벨에서는 상태 전이 규칙을 검증하고 DB 쿼리에서는 UPDATE 시 WHERE에 현재 상태 조건 포함해서 개발하는 것이 가장 이상적인 상태 전이 검증 개발 방법이다.

public void markPaymentDone(long rsvnId, String memNo) {

    // 1. 현재 상태 조회
    RentApp app = rentAppRepository.findById(rsvnId)
                          .orElseThrow(...);

    if (!app.getMemNo().equals(memNo)) {
        throw new BizException("본인 예약만 결제 가능");
    }

    // 2. 상태 전이 규칙 검증
    app.getState().canTransitTo(RsvnState.PAY_DONE);

    // 3. DB UPDATE (경쟁 조건 대비)
    int updated = jdbcTemplate.update("""
        UPDATE RENT_APP
           SET RSVN_STATE = 'PAY_DONE',
               PAY_DH     = SYSDATE
         WHERE RSVN_ID    = ?
           AND MEM_NO     = ?
           AND RSVN_STATE = 'PAY_WAIT'
    """, rsvnId, memNo);

    if (updated == 0) {
        throw new BizException("결제 상태가 유효하지 않습니다.");
    }
}

 

4. 서버 단 비즈니스 규칙 재검증

4-1. 프론트 검증은 “편의”, 서버 검증은 “진짜”

프론트에서 하는 검증은 UX를 위한 것이다.

  • 필수 필드 체크
  • 형식 체크(숫자 여부, 이메일 형식 등)
  • 즉시 피드백(“이 시간대는 마감되었습니다” 같은 안내)

하지만 보안 관점에서 봤을 때 프론트 검증은 “있으면 좋은 것”이지 “믿을 수 있는 것”이 아니다.

매크로/봇은 HTML/JS를 아예 불러오지 않고 API 엔드포인트(URL)만 수집해서 직접 HTTP 요청을 만들어 보낸다.

프론트에서 아무리 체크해도 서버가 다시 검증하지 않으면 그 규칙은 공격자에게 적용되지 않는다.

4-2. 서버에서 반드시 재검증해야 하는 규칙들

예를 들어 체육관 예약/강습 시스템에서는 이런 정책이 많다.

  1. 예약 가능 기간/시간 규칙
    • “사용일 14일 전 10:00부터 예약 가능”
    • “전일 23:59 이후에는 예약 불가”
    • 특정 월/일은 정기 휴관일로 예약 불가
  2. 1인당 이용 제한
    • “하루 최대 2타임”
    • “한 달 최대 10회”
    • “동일 시간대 중복 예약 불가”
  3. 자격/우대 조건
    • 해당 구민/타 구민 시간 차등 (양천구민 선접수 등)
    • 연령 제한(청소년/성인 프로그램)
    • 장애인/국가유공자 등 감면 대상 여부
  4. 요금/할인/감면 규칙
    • 기본 이용료 정책 (평일/주말/시간대별)
    • 우대/감면율
    • 복수 감면 중 어떤 것 적용 가능한지
  5. 동의 항목
    • 개인정보 수집/이용 동의
    • 제3자 제공 동의
    • 환불 규정 동의

이 모든 것은 서버에서 최종적으로 다시 확인해야 한다.

모든 검증을 프론트에서 완료해소 컨트롤러에서는 항상 프론트에서 진행한 검증을 다시 진행한 뒤에야 데이터 처리를 한다.

이렇게 개발해야 프론트에서 조작하든 매크로가 직접 값을 넣든 서버 기준 정책에 맞지 않으면 모든 요청을 거를 수 있다.

4-4. 정책 설정을 코드에서 떼어내기

실무에서는 정책이 자주 바뀐다.

  • “내년부터는 하루 이용 제한을 2회 → 3회로 변경”
  • “타구민 접수는 2일 뒤에서 3일 뒤로 변경”
  • “우대 감면율이 50% → 40%로 변경”

이럴 때마다 코드를 수정/배포하면 위험하다.

가능하면 정책을 설정 테이블/환경설정으로 분리하는 것이 좋다.

예를 들어

 

  • 예약 제한 정책
    • 구별 제한
    • 회원 유형별 제한
    • 요일별 제한
    • 1일 / 1개월 예약 가능 횟수 제한
  • 예약 가능 기간 정책
    • 프로그램(강좌)별 예약 가능 기간
    • 사용일 기준 며칠 전부터 예약 가능인지(오픈 시점 규칙)
  • 감면(할인) 정책
    • 감면 종류별 규칙
    • 감면율
    • 감면 가능 최대 금액

 

등의 설정을 DB에 저장해서 사용할 수 있다.

이것 이외에 여러가지 설정을 DB에 저장해서 사용할 수 있는데 IP / User-Agent / 국가·지역 기반 차단 정책을 위해서 IP 등을 지정해서 막을 수 있다.

또 허용 재시도 횟수 등을 빼서 이동적으로 변경시킬 수 있으면 보안적 측면과 더불어 트래픽을 조절할 수 있다.