한 줄 정의

실무 보안 사고의 대부분은 정교한 공격이 아니라 “로그인 여부만 확인하고 권한은 확인하지 않은” 기본기 누락에서 시작합니다.

쉽게 말하면

호텔 카드 키를 떠올려 봅시다. 프런트에서 신분증을 확인하고 카드 키를 내주는 것이 인증, 그 카드 키가 802호 문만 열리도록 되어 있는 것이 인가 입니다.

K사 사고는 투숙객 확인은 했지만 카드 키로 아무 방이나 열리게 만들어 둔 것과 같습니다. 게다가 열 방 번호를 손님이 직접 말하게(API 파라미터로 받게) 두었으니, 손님이 숫자를 바꿔 부르는 순간 전 객실이 열립니다.

이 장의 나머지도 전부 같은 결의 이야기입니다. 장부에 여권 번호를 그대로 적어 두지 않기(암호화), 로비 출입구를 하나만 열어 두기(방화벽), 누가 언제 어느 방에 들어갔는지 기록 남기기(감사 로그). 최신 보안 기술이 아니라 운영 수칙 수준의 기본기 를 지켰는지가 사고를 가릅니다.

왜 중요한가?

K사는 홈페이지 ‘요금 정보’ 페이지를 통해 1천만 건 이상의 고객 정보가 유출됐습니다. 요금 조회 API는 고객 코드를 파라미터로 받았습니다.

https://주소/...?cd=고객 코드

서버는 이 고객 코드가 로그인한 사용자의 코드인지 검증하지 않고 해당 고객 정보를 응답했습니다. 해커는 자기 계정으로 로그인한 뒤 임의의 코드를 만들어 API를 호출했고, 무작위로 만든 코드가 실제 고객 코드와 일치할 때마다 그 고객의 정보를 가져갔습니다.

H서비스의 암호 변경 API도 회원 식별자와 변경할 암호를 파라미터로 받았는데, 세 가지가 빠져 있었습니다.

  • 현재 요청이 로그인한 회원의 요청인지 확인하지 않음
  • 회원 식별자가 로그인한 회원의 식별자인지 검증하지 않음
  • 변경하기 전 암호를 검증하지 않음

API 구조만 알면 누구나 다른 회원의 암호를 바꿀 수 있었습니다. 다행히 해커가 관심을 갖기 전에 제거했습니다.

두 사례 모두 제로데이 취약점이나 정교한 공격 기법이 아니라 검증 코드 한 줄이 빠진 것 입니다. 보안은 조직 차원에서 대응해야 할 만큼 범위가 넓지만, 개발자 개인이 신경 쓰는 것만으로도 상당수 취약점은 사라집니다.

핵심 내용

인증과 인가는 서로 다른 검사입니다

인증(authentication)인가(authorization)
확인하는 것사용자가 누구인지요청한 기능을 실행할 권한이 있는지
빠졌을 때아무나 접근 가능로그인한 사람이면 남의 데이터까지 접근 가능

K사 사고는 인증은 통과시키고 인가를 빼먹은 전형입니다. 이 둘만 잘 지켜도 기본적인 취약점은 막힙니다.

토큰으로 사용자 식별하기

인증에 성공하면 서버는 문자열 토큰을 발급하고, 클라이언트는 이후 요청마다 토큰을 함께 보내 자신이 누구인지 증명합니다. 매번 아이디·암호를 입력받지 않기 위한 장치입니다.

sequenceDiagram
  participant C as 클라이언트
  participant S as 서버
  C->>S: 1. 인증 요청(아이디, 암호)
  Note over S: 1.1 아이디·암호 확인<br/>1.2 토큰 생성
  S-->>C: 1.3 인증 성공, 토큰 전송
  C->>S: 2. 요청(토큰 전송)
  Note over S: 2.1 토큰 검증 및 사용자 식별<br/>2.2 요청 처리
  S-->>C: 2.3 응답

토큰과 사용자를 이어 주는 매핑 정보를 어디에 두느냐로 두 갈래가 갈립니다.

별도 저장소에 매핑 정보 저장

DB나 레디스에 토큰 → 사용자 식별자 를 보관합니다. 생성 시간, 최근 사용 시간, 유효 시간, 클라이언트 버전 등을 함께 저장하며, 토큰 문자열은 중복으로 사용자가 잘못 매칭되지 않도록 고유하게 생성합니다.

용량 걱정은 대개 기우입니다. G 서비스는 340만 개의 토큰 데이터를 저장하는데 테이블 크기가 2.4G 정도로, 레디스 같은 메모리 캐시로도 충분합니다. 다만 서비스 규모가 100배 커져 3억 4천만 개가 되면 240G가 되므로, 그때는 비용과 구조를 고려해 DB 같은 저장소로 옮깁니다.

서버 메모리 에 저장하는 것도 이 방식의 변형입니다. 톰캣 같은 서블릿 컨테이너의 세션이 여기에 해당하고, 고유하게 생성되는 세션 ID가 곧 토큰입니다. 단점이 둘입니다.

  • 서버를 재시작하면 토큰 데이터가 사라짐
  • 생성 가능한 세션 개수가 메모리 크기에 제한됨

여기에 더해 서버마다 서로 다른 토큰 집합을 갖게 되므로 고정 세션(sticky session) 으로 로드 밸런서를 설정해야 합니다. 클라이언트가 A 서버에 저장된 토큰을 들고 B 서버로 가면 B는 그 토큰 데이터가 없어 요청을 처리하지 못하기 때문입니다.

스프링 세션은 메모리 대신 DB나 레디스에 세션 데이터를 저장해서 이 단점을 없앱니다. 외부 저장소를 쓰면서도 서블릿의 HttpSession 을 그대로 쓸 수 있다는 것이 장점입니다.

토큰 자체에 사용자 식별자 저장 (JWT)

로그인에 성공하면 사용자 식별자를 값으로 갖는 JWT를 만들어 토큰으로 응답하고, 요청이 오면 토큰을 파싱해서 사용자 식별자를 꺼냅니다.

// 발급: 사용자 식별자를 담은 JWT 문자열을 응답한다
String token = Jwts.builder()
        .subject("userid")
        .signWith(key)
        .compact();
 
// 검증: 토큰을 파싱해서 사용자 식별자를 구한다
Jws<Claims> jwt = Jwts.parser().verifyWith(key).build().parseSignedClaims(jws);
String userId = jwt.getPayload().getSubject();

장점은 토큰만 있으면 사용자가 누구인지 확인할 수 있다 는 점입니다. 별도 외부 DB가 필요 없어 서버 구조가 간단하고, 메모리에 토큰을 두지 않으므로 수평 확장도 쉽습니다.

단점도 여기서 나옵니다. 토큰 안에 데이터가 들어가므로 주고받는 데이터 크기가 커져 네트워크 트래픽이 증가 하고, 트래픽 규모가 크면 비용으로 직결되므로 토큰에는 최소한의 필요한 데이터만 넣어야 합니다. 또 하나는 서버에서 토큰 데이터를 제어할 수 없다 는 것입니다. 클라이언트에 저장되어 있으므로 서버에서 삭제하거나 변경할 수 없습니다.

토큰 송수신
방식특징
쿠키웹 사이트가 주로 사용. 브라우저가 모든 요청에 자동으로 전송하므로 별도 자바스크립트가 필요 없습니다. 서버 세션의 세션ID도 쿠키로 주고받습니다
헤더앱이 주로 사용. 이름은 Token, X-token, Auth 등 알맞게 정하며 OAuth 2.0처럼 Authorization 헤더를 쓰기도 합니다
토큰 보안

서버 보안을 철저히 해도 클라이언트가 취약하면 토큰이 탈취될 수 있고, 토큰을 탈취한 쪽은 원래 소유자처럼 행세할 수 있습니다. 완화 수단이 셋입니다.

유효 시간 제한. 기준이 두 가지로 나뉩니다.

기준동작
생성 시점9시에 유효 시간 1시간인 토큰을 만들면 10시에 만료됩니다
마지막 접근 시간유효 시간이 10분이고 마지막 접근이 11시면 만료는 11시 10분, 11시 5분에 다시 접근하면 11시 15분으로 밀립니다. 서블릿 세션이 이 방식입니다

유효 시간은 애플리케이션 성격에 맞게 정합니다. 너무 짧으면 조금만 사용하지 않아도 로그인이 풀려 불편하고, 반대로 관리자 사이트처럼 민감 정보를 조회할 수 있는 서비스는 길게 잡으면 안 됩니다. 브라우저를 켜 놓은 채 자리를 비우면 누군가 민감한 고객 정보를 조회할 수 있고, 탈취한 토큰으로 오랫동안 정보를 유출할 수도 있습니다.

클라이언트 IP 비교. 토큰을 생성할 때 접근한 IP와 실제 토큰을 전송한 IP가 다르면 비정상 접근으로 간주하고 요청 처리를 거부합니다.

토큰 무효화(강제 로그아웃). 외부 저장소 방식은 데이터를 삭제하거나 유효하지 않은 상태로 바꾸면 끝입니다. 토큰 자체에 데이터를 저장하는 방식은 서버에 토큰이 없으므로 추가 개발이 필요합니다. 예를 들어 사용자마다 “유효한 토큰 생성 시간 하한”을 두고, 그 이전에 생성된 토큰은 유효하지 않은 것으로 판단합니다.

액세스 토큰과 리프레시 토큰

액세스 토큰은 인증된 사용자임을 확인하는 용도로 만료 시간을 짧게(몇 분에서 몇 시간) 잡습니다. 짧으면 로그인이 자주 풀려 불편하므로, 로그인 성공 시 만료가 상대적으로 긴 리프레시 토큰을 함께 발급합니다. 액세스 토큰이 만료되면 리프레시 토큰으로 새 액세스 토큰을 발급받아, 리프레시 토큰이 만료될 때까지 재로그인 없이 인증 상태를 유지합니다.

인가와 접근 제어 모델

접근 제어의 기본은 접근한 사용자를 토큰이나 세션으로 식별하는 것 입니다. API 요청 파라미터로 로그인한 사용자의 식별자를 받으면 안 됩니다. K사와 H서비스가 정확히 이 지점에서 뚫렸습니다.

@GetMapping("/myinfo")
public ResponseEntity<?> getMyInfo(@RequestHeader("token") String token) {
    String userId = getUserIdByToken(token);  // 토큰으로 사용자 식별값 구함
    MyInfoResponse info = myInfoService.getMyInfo(userId);
    return ResponseEntity.ok(info);
}

여기서 한 단계 더 나아가 사용자마다 실행할 수 있는 기능에 차이를 두려면 접근 제어 모델이 필요합니다. 대표적인 것이 RBAC(Role-Based Access Control) 로, 역할별로 실행 가능한 기능 집합을 할당하고 사용자에게는 역할을 부여합니다.

flowchart LR
  A1["계정 1"] --> R1["주문 운영자"]
  A2["계정 2"] --> R1
  A2 --> R2["상품 관리자"]
  A3["계정 3"] --> R2
  R1 --> F1["주문 조회"]
  R1 --> F2["주문 환불 처리"]
  R1 --> F3["주문 취소"]
  R2 --> F4["상품 등록"]
  R2 --> F5["상품 판매 중지"]

계정 2처럼 두 역할을 가지면 5개 기능 전부에 대한 실행 권한을 갖습니다. 역할을 두지 않고 사용자마다 개별적으로 권한을 부여할 수도 있습니다.

모델방식적합한 상황
RBAC역할에 기능 집합을 할당하고 사용자에게 역할 부여권한을 체계적으로 관리해야 할 때
사용자별 권한사용자에게 기능을 직접 부여시스템 규모가 작거나 역할을 나누기 애매할 때
ABAC사용자 속성(예: IP 주소)으로 접근 제어정교한 제어가 필요할 때

RBAC의 장점은 새 직원이 입사했을 때 알맞은 역할 몇 개만 계정에 부여하면 끝난다 는 점입니다. 대신 역할 설계와 관리에 신경 써야 합니다. 무분별하게 정의하면 중복된 기능을 가진 유사 역할이 계속 생기고 사용하지 않는 역할도 계속 남아, 역할 개수가 불필요하게 늘고 관리가 복잡해집니다.

사용자별 권한 부여는 역할별 부여보다 구현이 단순해서 개발 시간이나 우선순위를 고려해 선택하기도 합니다. ABAC은 보다 정교한 제어가 가능하지만 그만큼 구현이 복잡해지고 속성·규칙을 정의하는 데도 시간이 많이 듭니다.

실무에서는 단독으로 쓰기보다 함께 씁니다. 단순 로그인 여부 확인에 RBAC과 사용자별 권한을 조합하는 식입니다. 접근 제어가 정교해질수록 복잡해지므로 실제로 필요한 수준까지만 설계합니다.

운영 계정을 공유하지 않습니다

직원이 몇 명 안 되고 서비스 규모가 작을 때 관리자 계정을 여러 명이 공유하기도 하는데 매우 위험합니다. 현금처럼 쓸 수 있는 포인트를 지급하는 기능이 있다고 하면, 모든 운영자가 같은 계정을 쓰는 순간 누가 지급했는지 식별할 수 없습니다. 누군가 악의적으로 지인에게 포인트를 무단 지급해도 책임 소재를 파악할 수 없습니다. 최소한 운영자마다 별도 계정을 발급해서 누가 어떤 기능을 실행했는지 추적할 수 있어야 합니다.

데이터 암호화

비밀번호가 유출되면 그 계정으로 쉽게 로그인할 수 있고, 로그인에 성공하면 비밀번호 변경은 물론 다양한 기능을 실행할 수 있습니다. 여러 서비스에서 동일한 비밀번호를 쓰는 경향 때문에 한 서비스의 유출이 다른 서비스까지 위협합니다.

중요한 것은 위협이 외부에만 있는 게 아니라는 점 입니다. DB에 접근할 수 있는 엔지니어가 회원 테이블을 조회해서 비밀번호를 볼 수 있다면 그 자체로 보안 위협입니다. 대부분의 엔지니어는 악용하지 않지만 사람을 100% 신뢰할 수는 없고, 엔지니어의 PC가 해킹당할 가능성도 있습니다.

암호화해 두면 유출되더라도 원래 값을 알아내기 어렵고, 알아내더라도 상당한 시간이 걸립니다. 그 사이에 비밀번호를 변경하는 등 사후 대응할 시간을 법니다.

단방향 암호화 — 해시

복호화할 수 없는 방식으로, 해시 함수로 데이터를 해시 값으로 변환합니다. SHA-256, MD5, BCrypt 등이 있습니다. 원본을 유추하기 어렵게 하기 위해 원본이 조금만 달라도 완전히 다른 해시 값이 나옵니다(‘가나다라마’는 df2ef824로, ‘가나다마’는 fa262235로 시작).

실제 암호화는 바이트 데이터 기준 으로 동작하므로 문자열은 알맞은 캐릭터셋으로 바이트 배열로 변환해서 전달하고, 결과를 저장소에 읽을 수 있는 형태로 넣으려면 16진수나 Base64로 다시 문자열화합니다.

public static String encrypt(String input) {
    StringBuilder hexString = new StringBuilder();
    try {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(input.getBytes("UTF-8"));
        for (byte b : hash) {
            String hex = Integer.toHexString(0xff & b);
            if (hex.length() == 1) hexString.append('0');
            hexString.append(hex);
        }
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
    return hexString.toString();
}

비밀번호 확인은 복호화가 아니라 해시 값 비교 입니다. 가입 시 저장해 둔 해시와 로그인 시 입력값의 해시를 비교해 같으면 올바르게 입력한 것으로 판단합니다. 같은 이유로 기존 비밀번호를 알려주는 기능은 구현할 수 없습니다. 임의의 문자열로 초기화한 뒤 등록된 이메일이나 문자로 전달하는 방식만 가능합니다.

충돌 저항성(collision resistance)

해시 값은 길이가 일정하므로 서로 다른 데이터가 같은 해시 값을 가질 수 있습니다. 동일한 해시 값을 갖는 서로 다른 데이터를 찾기 어려울 때 충돌 저항성을 갖는다고 합니다. 생성 결과가 길수록 충돌 가능성이 줄어들어, SHA-256(32바이트)보다 SHA-512(64바이트)가 충돌 가능성이 낮습니다.

솔트(salt)

같은 알고리즘이면 같은 원본에 항상 같은 해시 값이 나옵니다. 이 특성 때문에 해커가 문자열과 해시 값을 미리 계산해서 만든 표, 즉 레인보우 테이블 로 원본을 역추적할 수 있습니다. 탈취한 해시 값이 표에 있으면 해당 원본으로 로그인을 시도하고, 성공하면 시스템이 어떤 알고리즘을 쓰는지까지 확인됩니다.

솔트는 암호화할 때 함께 섞는 임의의 값입니다. 솔트가 달라지면 같은 원문이라도 결과 해시가 달라지므로, 동일한 솔트와 동일한 알고리즘으로 표를 새로 만들지 않는 한 원본을 추측하기 어렵습니다. 사용자마다 고유한 값을 생성해서 솔트로 쓰면 보안 강도가 더 올라갑니다.

MessageDigest digest = MessageDigest.getInstance("SHA-256");
digest.update(salt.getBytes());                   // salt 추가
byte[] hash = digest.digest(input.getBytes("UTF-8"));
양방향 암호화

암호화와 복호화가 모두 가능한 방식으로, SSH나 HTTPS처럼 보안이 중요한 데이터 송수신에 주로 쓰입니다. 같은 알고리즘·같은 원본이라도 키(key) 가 다르면 결과가 달라집니다.

대칭 키비대칭 키
키 사용암호화·복호화에 동일한 키공개 키로 암호화, 개인 키로 복호화
키 공유양쪽이 같은 키를 공유해야 함공개 키만 공유, 개인 키는 소유자만 접근
보안키가 유출되면 누구나 복호화 가능공개 키가 유출돼도 복호화 불가
대표 알고리즘AESRSA

개인 키로 암호화하고 공개 키로 복호화할 수도 있는데, 이쪽은 암호화보다 신원 확인·서명 목적입니다. SSH 서버에 공개 키를 등록해 두고 클라이언트가 접속할 때 개인 키로 인증하는 것이 대표적입니다.

AES — 키와 IV

AES는 키로 128/192/256비트(16/24/32바이트) 중 하나를 쓰며, 무작위로 생성해서 유추가 어려워야 합니다.

여기에 IV(Initialization Vector, 초기화 벡터) 가 함께 필요합니다. 같은 키로 같은 데이터를 암호화하면 항상 같은 결과가 나오는데, 이렇게 반복되는 패턴은 공격자에게 암호화된 데이터를 분석할 단서를 줍니다. IV는 임의의 바이트 배열(AES는 길이 16)로, 암호화할 때 함께 사용하면 같은 키를 쓰더라도 결과값이 매번 달라집니다. 복호화할 때도 필요하므로 IV 역시 안전하게 전달하거나 저장해야 합니다.

Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");   // 알고리즘/모드/패딩
IvParameterSpec parameterSpec = new IvParameterSpec(iv);
cipher.init(Cipher.ENCRYPT_MODE, key, parameterSpec);
byte[] encrypted = cipher.doFinal(plain.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(encrypted);

"AES/CBC/PKCS5Padding" 은 각각 암호화 알고리즘 / 암호화 모드 / 패딩 방식 입니다. AES는 정해진 길이의 블록 단위로 암호화하므로 마지막 블록은 길이가 맞지 않을 때가 많고, 이 빈 자리를 채우는 규칙이 패딩입니다. GCM 모드처럼 패딩이 필요 없는 모드에서는 "NoPadding" 을 씁니다.

단방향 암호화와 마찬가지로 암복호화도 바이트 데이터를 대상으로 하므로, 결과 바이트 배열은 Base64나 16진수로 인코딩해서 문자열로 표시합니다.

HMAC으로 데이터 위변조 검증

게임이 끝난 뒤 결과에 따라 포인트를 지급하는 API를 생각해 봅시다. 클라이언트가 {"result": "A", "bonus": 10} 을 보낸다면, 서버는 이 값이 유효한 클라이언트가 생성해서 보낸 값인지 확인해야 합니다. 중간에서 누군가 데이터를 위변조하면 실제 지급해야 하는 것보다 더 많은 포인트를 주게 되기 때문입니다.

HMAC(Hash-based Message Authentication Code) 은 해시 함수와 비밀 키를 이용해 두 가지를 보장합니다.

  • 메시지 무결성: 메시지가 중간에 위변조되지 않았음
  • 인증: 메시지 발신자를 인증할 수 있음(발신자만 비밀 키에 접근)
flowchart TB
  subgraph S["메시지 발신자"]
    direction LR
    M1["메시지"] --> H1["HMAC 함수"]
    K1["비밀 키"] --> H1
    H1 --> MAC1["MAC"]
  end
  subgraph R["메시지 수신자"]
    direction LR
    M2["수신한 메시지"] --> H2["HMAC 함수"]
    K2["비밀 키"] --> H2
    H2 --> MAC2["재생성한 MAC"]
  end
  MAC1 -->|"메시지 + MAC 전송"| M2
  MAC1 -.->|"두 MAC 비교"| MAC2

두 값이 같으면 메시지가 변경되지 않았음을 보장할 수 있고, 다르면 유효하지 않은 것으로 판단합니다.

Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(), "HmacSHA256");
mac.init(secretKeySpec);
byte[] hash = mac.doFinal(message.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(hash);

장점은 단순함과 효율성입니다. 발신자와 수신자가 비밀 키만 공유하면 정해진 해시 알고리즘으로 MAC을 만들 수 있으므로 낮은 비용으로 인증 보안을 구현 할 수 있고, 사용하는 해시 알고리즘에 따라 보안성도 함께 올라갑니다. 단점은 비밀 키 관리입니다. 유출되면 보안이 무너지고, 유출 위험이 있는 만큼 키 교체도 까다로울 수 있습니다.

네트워크·운영 차원의 방어

방화벽

서버가 외부에 노출되기 시작하면 포트 스캔부터 다양한 공격이 들어옵니다. 방화벽은 물리 장비로 존재하기도 하고 가상 방화벽(AWS 보안 그룹 등)으로 존재하기도 합니다.

방향원칙
인바운드 (외부→내부)필수 트래픽만 허용. 모든 IP의 모든 포트가 아니라 특정 서버 IP의 443 포트만 허용하고, 접속을 허용할 클라이언트 IP도 가능한 최소 범위로 지정
아웃바운드 (내부→외부)정해진 목적지로만 허용. 전부 허용하면 서버가 해킹당했을 때 해커의 중간 경유지로 악용될 수 있음

인바운드는 기능 성격에 따라 나눕니다. 서비스 API는 외부 모든 IP에서 서버A의 443 포트로 열되, 관리자 API는 사내 IP만 서버B의 443 포트로 접근하게 하는 식입니다.

웹 방화벽(WAF) 은 HTTP/HTTPS 수준에서 발생하는 공격, 즉 SQL 인젝션이나 XSS 같은 웹 기반 위협을 감지하고 차단합니다. 방화벽이나 웹 방화벽을 쓰고 있지 않다면 최소한 서버 OS의 방화벽 기능이라도 설정해 시스템을 보호해야 합니다.

감사 로그(audit log)

특정 작업·절차·사건 또는 장치에 영향을 주는 활동의 순서를 입증하는 보안 관련 기록입니다. 로그인/로그아웃 내역, 암호 초기화·설정 변경 내역, 환자 기록을 조회한 의료진 정보, 계약서 수정 이력 등이 대표적인 기록 대상입니다.

감사 로그일반 로그
목적컴플라이언스·정책 준수를 위한 활동 기록버그·오류 해결 지원
기록필수로그 레벨에 따라 생략 가능
보관규정에 정해진 기간 동안상황에 따라(디스크 부족 등) 삭제

산업군에 따라 법적으로 요구되는 보안 인증이 있고 인증 항목에 접속 기록 같은 감사 로그가 포함됩니다. 통과하지 못하면 과태료 등 처분을 받을 수 있으므로, 감사 로그는 사고 시 원인 추적 자료이면서 동시에 규정 대응 수단 입니다.

데이터 노출 줄이기

백오피스 회원 목록에 아이디·이름·휴대폰 번호가 표시되고 한 페이지에 50명이 나온다면, 자동화 도구 없이도 한 번에 50명의 개인 정보를 얻을 수 있습니다.

  • 마스킹: 휴대폰 번호나 주소 일부를 가립니다. 이때 서버에서 클라이언트로 응답하는 데이터 자체가 마스킹되어 있어야 합니다. API는 원본을 주고 프런트 코드에서만 처리하면 브라우저 개발자 도구로 손쉽게 조회됩니다
  • 권한 축소: 소수 인원에게만 목록 조회 권한을 주고, 나머지는 고객이 불러 준 번호나 이름으로 조회하게 합니다
  • 이상 접근 감지: 목록 기능을 짧은 시간 동안 빈번하게 실행하거나 상세 정보를 짧은 간격으로 조회하는 사용자를 비정상으로 인지하고 차단합니다. 자동화 수집 자체를 막을 방법은 없지만 피해 규모는 줄일 수 있습니다
  • 로그 메시지: 2018년 트위터는 로그에 사용자의 비밀번호가 평문으로 기록되는 문제가 있었습니다. 로그를 조회할 수 있는 개발자는 누구든 비밀번호를 알 수 있었습니다
비정상 접근 처리

평소와 다른 장소나 기기에서 로그인하거나 로그인에 여러 차례 실패하면 비정상 접근으로 판단하고 사용자에게 알립니다. 계정 관리 책임을 사용자에게만 맡기지 않고 시스템적으로 보안을 강화하는 것 이 핵심입니다.

알리는 것을 넘어 연속으로 로그인에 실패하면 계정을 일시적으로 잠그기도 합니다. 가능한 모든 값을 대입하는 브루트 포스 공격 에 대응하기 위해서입니다. 고객 정보 조회 API를 지속적으로 반복 호출하거나 권한이 없는 URL·API에 접근을 시도하는 것도 부정 사용으로 간주해 계정을 잠글 수 있습니다.

시큐어 코딩

String id = request.getParameter("id");
String query = "select id, name from member where id = '" + id + "'";
ResultSet rs = stmt.executeQuery(query);

사용자가 ' or 1=1 or id = ' 를 입력하면 실제 실행되는 쿼리는 다음과 같아집니다.

select id, name from member where id = '' or 1=1 or id = ''

where 절의 조건이 or로 연결되어 있고 중간에 항상 참인 1=1 이 있으므로, 결과적으로 member 테이블의 모든 id와 name이 조회됩니다. 전형적인 SQL 인젝션(injection) 으로, 코드의 취약점을 이용해 SQL 쿼리에 코드를 삽입하는 공격입니다. 간단한 공격이지만 한 번 뚫리면 피해가 큽니다. 국내에서도 SQL 인젝션으로 200만 건에 가까운 개인정보가 유출된 사례가 있습니다.

가장 쉬운 방어는 Prepared Statement 입니다. 값에 포함된 특수 문자(작은 따옴표 등)를 알맞게 변환해서 SQL을 만들어 주기 때문입니다.

그 밖에 서버 프로그램을 개발할 때 신경 써야 할 항목입니다.

항목내용
입력 값 검증클라이언트가 전송한 값이 올바르다고 가정하지 않고 필수 여부·길이 제한·미허용 값 등을 모두 검증
민감 정보 암호화로그인 암호·바이오 정보뿐 아니라 주민 번호, 운전 면허 번호 같은 고유 식별 정보도 암호화
에러 메시지내부 IP나 DB IP 같은 시스템 정보가 노출되지 않게
보안 통신HTTPS처럼 데이터를 암호화해서 유출 방지
CORS허용된 도메인만 서버 자원에 접근하도록 제한
CSRF타 사이트에서 들어오는 위조 공격 방지 — CSRF 토큰, SameSite 쿠키, 캡차

개인 보안

개발자는 권한에 따라 다양한 서버에 연결하고 DB에 접속해 여러 쿼리를 실행할 수 있습니다. 접근할 수 있는 시스템이 많은 만큼 개발자 PC가 해킹당하면 큰 사고로 이어집니다. 출처가 불분명한 파일을 받거나 의심스러운 이메일의 첨부 파일을 실행하는 것이 위험한 이유입니다. 개발자 PC에 악성 코드를 심고 키로깅으로 DB 접속 암호를 알아내 정보를 탈취한 사례, 서버 담당자가 랜섬웨어에 걸려 데이터를 복구하려고 돈을 지불한 사례가 실제로 있습니다.

물리적인 보안도 중요합니다. 자리를 비울 때는 화면 보호기로 잠그고, 민감 정보가 담긴 출력물은 파기하거나 유출되지 않게 간수합니다.

보안과 비용

방화벽·웹방화벽은 장비를 사거나 클라우드 서비스를 추가로 써야 하고, 감사 로그에는 스토리지가, 비정상 접근 탐지에는 개발 공수가, 접근 권한 관리에는 솔루션 구매 비용이 듭니다. 보안 수준을 높일수록 비용이 증가하므로 초기 스타트업은 투자하기가 쉽지 않습니다. 그래도 보안 자체에 무신경하면 안 됩니다. 최소한 인증과 인가에 신경 쓰고 주요 정보를 암호화하며 서버 수준에서 방화벽이라도 설정하면, 보안 사고가 발생할 확률을 낮출 수 있습니다.

비교 / 트레이드오프

토큰 매핑 정보를 어디에 두느냐가 서버 구조를 결정합니다.

별도 저장소(DB·레디스)서버 메모리(서블릿 세션)토큰 자체(JWT)
사용자 식별저장소 조회메모리 조회토큰 파싱
수평 확장쉬움고정 세션 필요쉬움
서버 재시작유지소실무관
네트워크토큰 문자열만세션ID만담은 데이터만큼 증가
토큰 무효화삭제하면 끝삭제하면 끝추가 개발 필요

암호화는 “원본을 다시 봐야 하는가”와 “무엇을 지키려는가”로 갈립니다.

요구선택
원본을 다시 볼 필요가 없음(로그인 비밀번호)단방향 해시 + 솔트
원본을 다시 봐야 함(주민 번호 등 고유 식별 정보)양방향 대칭 키(AES)
키를 공유할 수 없는 상대와 주고받음비대칭 키(RSA)
원본은 그대로 두되 위변조만 막음HMAC

마지막 행이 특히 헷갈리는 지점입니다. HMAC은 메시지를 숨기는 기술이 아니라 메시지가 바뀌지 않았음을 증명하는 기술입니다.

내 생각

  • “파라미터로 받은 식별자를 그대로 믿지 않는다”가 이 장에서 가장 실행 가능한 한 줄입니다. K사·H서비스 사고가 전부 여기서 났고, 코드 리뷰에서 @RequestParam userId 같은 시그니처를 보면 반사적으로 멈춰서 확인해야 하는 이유입니다.
  • JWT를 “확장에 유리해서” 고르는 판단은 절반만 맞습니다. 강제 로그아웃이나 권한 즉시 회수가 요구사항에 있으면 결국 블랙리스트 저장소를 다시 만들게 되므로, 상태를 없앤 대가가 어디서 돌아오는지 먼저 확인해야 합니다.
  • RBAC의 진짜 비용은 도입이 아니라 역할 청소입니다. 비슷한 역할이 계속 늘어나는 것은 “역할을 새로 만드는 게 기존 역할을 고치는 것보다 쉬워서” 생기므로, 역할 생성 절차에 최소한의 마찰을 넣어 두는 편이 낫습니다.
  • 마스킹을 프런트가 아니라 API 응답에서 해야 한다는 원칙이 실무에서 가장 자주 어겨집니다. 화면 요구사항으로 내려오다 보니 프런트 작업으로 잡히기 쉬운데, 이 경우 개발자 도구 한 번으로 무력화됩니다.
  • 감사 로그는 “보안 기능”이 아니라 “규정 대응 기능”으로 잡아야 예산이 붙습니다. 인증 심사 항목이라는 근거가 있으면 스토리지 비용을 설득하기 쉽고, 실제 사고가 났을 때 원인 추적 자료로 그대로 쓰입니다.
  • 비용 트레이드오프에서 “인증·인가 + 민감 정보 암호화 + 서버 방화벽”이라는 하한선이 현실적입니다. 스타트업에서 보안 투자를 전부 미루는 결정을 자주 보는데, 이 셋은 추가 비용이 거의 들지 않으면서 사고 확률을 가장 크게 낮춥니다.

관련 개념