본문 바로가기
개발

은행 API가 개인정보를 흘린다면, 제일 먼저 볼 4곳

by 요즘IT 2026. 7. 19.

API로 개인정보가 새는 사고, 뉴스에서 종종 보죠.

근데 여기서 오해가 하나 있어요. 그런 사고가 무슨 대단한 해킹 때문에 터지는 것 같잖아요? 아니에요.

"고객 계좌번호를 아무한테나 보내주세요"라고 코드를 짜는 개발자는 세상에 없어요.

그럼 왜 새냐고요? 훨씬 조용하고 사소한 데서 새거든요. 프론트엔드가 필요로 하지도 않은 필드가 응답에 끼어 있다거나, 로그인은 확인했는데 "이 사람이 이 계좌 주인 맞나"는 안 봤다거나, 디버깅하려고 찍어둔 로그 한 줄에 카드번호가 평문으로 몇 달째 쌓여 있다거나.

어? 이거 좀 뜨끔하죠?

저도 그랬어요. 이 글을 정리하면서 제가 운영하는 서버 코드를 다시 열어봤거든요. 솔직히 말하면 네 개 중에 두 개는 제대로 안 되어 있더라고요.

한 백엔드 엔지니어가 "금융권 API에 정보 유출 문제가 있다면 나는 이 순서로 확인하겠다"고 쓴 글이 있어서, 거기에 제 경험을 얹어 정리해봅니다. 예제는 스프링 코드로 작성했습니다.


1. 응답에 "실제로" 뭐가 실려 나가는지 보세요

제일 흔한 유출은 사실 보안 버그도 아니에요. 그냥 엔티티를 그대로 반환한 것이에요.

이런 코드 익숙하시죠?

@Entity
public class Customer {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private String residentNumber;  // 주민등록번호
    private String passwordHash;    // 비밀번호 해시
}

@RestController
@RequestMapping("/customers")
public class CustomerController {

    @GetMapping("/{id}")
    public Customer getCustomer(@PathVariable Long id) {
        return customerService.findById(id);  // 엔티티 통째로 반환
    }
}

빨리 만들 땐 이렇게 하죠. 저도 했어요.

근데 이 코드, 엔티티에 있는 필드가 전부 클라이언트로 날아가요. 주민번호도, 비밀번호 해시도요. 프론트는 이름만 필요했는데 말이죠.

여기서 진짜 무서운 건 따로 있어요. 지금은 필드가 4개뿐이라 눈에 보여요. 근데 6개월 뒤에 누가 엔티티에 internalMemo 같은 필드를 하나 슬쩍 추가하면요? 그것도 자동으로 응답에 실려 나가요. 아무도 모르게.

비유하자면, 손님한테 서류 한 장 보여달라고 했는데 서류철을 통째로 건네주는 거예요. 지금은 안에 뭐가 들었는지 아니까 괜찮은데, 나중에 누가 그 서류철에 뭘 더 끼워 넣으면 그것까지 같이 넘어가는 거죠.

해법은 나가는 모양을 명시적으로 정하는 거예요.

public record CustomerResponse(
        Long id,
        String name
) {
    public static CustomerResponse from(Customer customer) {
        return new CustomerResponse(customer.getId(), customer.getName());
    }
}

@GetMapping("/{id}")
public CustomerResponse getCustomer(@PathVariable Long id) {
    return CustomerResponse.from(customerService.findById(id));
}

이게 포인트예요. "뭘 뺄까"가 아니라 "뭘 내보낼까"로 생각하는 거예요.

@JsonIgnore를 엔티티에 붙이는 방법도 있어요. 근데 그건 "빼는" 방식이라, 새 필드가 추가되면 또 까먹어요. DTO는 "넣는" 방식이라 새 필드가 생겨도 기본이 비공개예요. 안전한 쪽이 기본값이 되는 거죠.

API DTO layer diagram entity response
API DTO layer diagram entity response


2. "로그인했나" 말고 "이 사람 것이 맞나"를 보세요

여기가 제일 많이 놓치는 지점이에요. 그리고 제일 위험한 지점이기도 하고요.

인증(Authentication)은 **"당신 누구세요?"**에 답해요. 토큰 유효한지 보고, 로그인한 사용자인지 확인하죠.

근데 여기서 답 안 된 질문이 하나 남아요. "그래서 당신이 이 데이터를 볼 자격이 있나요?"

이게 인가(Authorization)예요. 완전히 다른 질문이에요.

금융 API에서 놀랄 만큼 흔한 버그가 이거예요. 토큰은 꼼꼼히 검사해요. 그러고 나서 /accounts/1234 요청이 오면 그 계좌가 누구 것이든 그냥 내줘요.

/accounts/1234가 내 계좌면, /accounts/1235는 누구 계좌일까요? 남의 계좌죠. 근데 로그인은 했잖아요. 그러니까 통과돼요.

호텔로 비유하면 이래요. 프런트에서 신분증 확인 철저히 해요. 투숙객 맞네요. 그리고 마스터키를 줘요. 투숙객인 건 맞는데, 그 사람 방이 몇 호인지는 안 본 거예요.

스프링에서는 이렇게 막아요.

@Service
public class AccountService {

    public Account findOwnedAccount(Long accountId, Long userId) {
        Account account = accountRepository.findById(accountId)
                .orElseThrow(() -> new AccessDeniedException("접근 권한이 없습니다."));

        if (!account.getOwnerId().equals(userId)) {
            throw new AccessDeniedException("접근 권한이 없습니다.");
        }
        return account;
    }
}

여기서 디테일 하나. "계좌 없음"이랑 "권한 없음"을 같은 응답으로 처리했어요.

왜냐면 404랑 403을 구분해주면, 공격자가 그걸로 "이 계좌번호는 존재하는구나"를 알아낼 수 있거든요. 응답 차이만으로 정보가 새는 거예요. 그래서 둘 다 똑같이 막았어요.

컨트롤러에선 인증 정보에서 사용자를 꺼내 씁니다.

@GetMapping("/accounts/{id}")
public AccountResponse getAccount(
        @PathVariable Long id,
        @AuthenticationPrincipal CustomUserDetails user) {

    Account account = accountService.findOwnedAccount(id, user.getId());
    return AccountResponse.from(account);
}

핵심은 요청에 담겨 온 사용자 ID를 믿지 않는 것이에요. 클라이언트가 보낸 userId 파라미터를 그대로 쓰면요? 그건 그냥 바꿔서 보내면 그만이에요. 반드시 인증된 토큰에서 꺼낸 값을 써야 해요.


3. 로그를 열어보세요. 진짜로요.

솔직히 저도 이거 보고 뜨끔했어요.

로그는 API 응답만큼 신경 써서 검토하는 사람이 거의 없거든요. 그래서 여기서 새면 아무도 몰라요. 몇 달이고요.

디버깅할 때 이런 코드 한 번쯤 넣어보셨죠?

log.info("요청 바디: {}", requestBody);  // 위험!

이거 편해요. 근데 이 한 줄이 카드번호, 주민번호, 주소를 평문으로 로그 파일에 차곡차곡 쌓아요. 그리고 그 로그는 보통 어디로 가나요? 로그 수집 서버로 가고, 백업되고, 때론 외부 모니터링 서비스로도 가요.

응답은 암호화해서 보내놓고, 로그엔 평문으로 남기는 거예요. 어? 이상하죠?

간단한 마스킹 유틸을 하나 만들어두면 좋아요.

public class LogMasker {

    private static final List<String> SENSITIVE_KEYS =
            List.of("password", "residentNumber", "cardNumber", "accountNumber");

    public static Map<String, Object> mask(Map<String, Object> body) {
        Map<String, Object> masked = new HashMap<>(body);
        for (String key : SENSITIVE_KEYS) {
            if (masked.containsKey(key)) {
                masked.put(key, "[MASKED]");
            }
        }
        return masked;
    }
}

그리고 이걸 로깅하는 자리에서 거쳐 가게 하는 거죠.

log.info("요청 바디: {}", LogMasker.mask(requestBody));

6개월 뒤의 디버깅 세션이, 고객 정보가 엉뚱한 데로 흘러간 이유가 되지 않게 하는 거예요.

여기서 하나 덧붙이면요. 예외 로그도 조심해야 해요. 스택 트레이스에 쿼리 파라미터가 통째로 찍히는 경우가 있거든요. 특히 JPA 예외가 그래요. 운영 환경에선 SQL 파라미터 로깅 끄는 걸 권해요.


4. 하나씩 긁어가는 건 못 막고 계실 거예요

마지막은 필드 하나가 새는 문제가 아니에요.

ID를 1번, 2번, 3번… 이렇게 하나씩 돌려가며 조용히 전부 긁어가는 것이요.

앞의 3번까지 완벽하게 막았다고 쳐요. 그래도 이건 별개 문제예요. 왜냐면 각각의 요청은 다 정상이거든요. 인증도 통과, 권한도 통과. 근데 그걸 초당 수백 번 한다면요?

사람은 그렇게 안 해요. 사람이 1초에 API를 200번 부를 순 없잖아요. 그러니까 속도 자체가 신호예요.

스프링에선 Bucket4j 같은 라이브러리를 쓰거나, 간단하게는 이런 식으로 접근해요.

@Component
public class RateLimiter {

    private final Map<Long, Bucket> buckets = new ConcurrentHashMap<>();

    public boolean tryConsume(Long userId) {
        Bucket bucket = buckets.computeIfAbsent(userId, k ->
                Bucket.builder()
                        .addLimit(Bandwidth.classic(20,
                                Refill.intervally(20, Duration.ofMinutes(1))))
                        .build());
        return bucket.tryConsume(1);
    }
}

분당 20회로 제한하는 거예요. 정상 사용자는 절대 안 걸려요. 근데 긁어가려는 쪽은 바로 걸려요.

여기서 중요한 건 완벽하게 막는 게 목적이 아니라는 것이에요. 대규모로 긁어가는 걸 의미 있게 느리게 만들고, 눈에 띄게 만드는 것이 목적이에요. 하루면 끝날 일이 석 달 걸리면, 그 사이에 알아챌 수 있잖아요.

실무에선 서버 앞단(Nginx 같은)에서 IP 기준으로 한 겹, 애플리케이션에서 사용자 기준으로 한 겹, 이렇게 이중으로 거는 게 일반적이에요. IP만 막으면 우회가 쉽고, 사용자만 막으면 계정을 여러 개 만들면 되니까요.


결국 핵심은, 어려운 게 아니라 빼먹는 거예요

네 가지 다시 정리하면 이래요.

  • 응답: 엔티티 말고 DTO로. "뭘 뺄까"가 아니라 "뭘 내보낼까"로.
  • 권한: 로그인 확인이랑 소유권 확인은 완전히 다른 일. 둘 다 하세요.
  • 로그: 민감 필드는 찍기 전에 마스킹. 로그는 아무도 안 보니까 더 위험해요.
  • 속도: 하나씩 긁어가는 걸 느리게, 그리고 눈에 띄게.

솔직히 이 중에 기술적으로 어려운 게 하나도 없어요. 다 스프링이 기본으로 제공하는 것들로 되거든요.

어려운 건 도구가 아니에요. 민감한 데이터를 다루는 모든 엔드포인트에 이걸 빠짐없이 적용하는 거예요. "여긴 딱 봐도 위험하네" 싶은 곳은 다들 신경 써요. 문제는 안 위험해 보이는 곳이죠.

제가 제 코드 열어봤을 때도 그랬어요. 결제 관련 API는 꼼꼼히 막아놨는데, 사용자 프로필 조회 API는 엔티티 그대로 반환하고 있더라고요. "이건 별거 아니니까" 하고 넘어간 자리에서 새는 거예요.

한번 해보세요. 지금 운영 중인 API 하나만 골라서, 응답을 실제로 찍어보는 거예요. 예상한 필드만 나오나요? 로그 파일도 한 번 grep으로 훑어보시고요. 30분이면 충분해요.

저는 다음 글에서 이걸 스프링 시큐리티 설정 단에서 어떻게 아예 강제로 걸어두는지 풀어볼 생각이에요.