Back-End/Spring

[Spring] GeoIP를 이용한 해외 IP 차단

seungwook_TIL 2025. 1. 16. 21:24

MaxMind 에서 데이터베이스 다운로드

MaxMind에서 먼저 데이터베이스를 받아와야한다.

국가를 제외한 나머지 자료(시, 도 등)는 꽤 부정확하다는 글이 많고, 해외인지 아닌지가 가장 중요하기 때문에 country 데이터베이스만 사용하기로 했다.

아래 사이트에서 회원 가입을 한 후 country 데이터베이스를 다운로드 받는다.(GeoLite2-Country.mmdb)

https://www.maxmind.com/en/home

 

스프링에 적용

의존성 추가

build.gradle에 geoip 의존성을 추가해준다.

implementation "com.maxmind.geoip2:geoip2:4.1.0"

 

서비스 로직 작성

아래 서비스 계층에는

  • getClientIP() : request 객체를 바탕으로 IP를 추출하는 메서드 
  • getCountryByIp(String ip) : IP를 바탕으로 영문 국가명을 반환하는 메서드

이렇게 두 개의 주요 메서드가 존재한다.

getCountryByIp는 같은 네트워크에 접속된 아이피는 국가명이 아닌 "내부 아이피" 문자열을 반환한다.

import com.maxmind.geoip2.DatabaseReader;
import com.maxmind.geoip2.model.CountryResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.io.File;
import java.net.InetAddress;

@Service
public class IpService
{
    private final DatabaseReader dbReader;

    private static final String[] IP_HEADER_CANDIDATES = {
            "X-Forwarded-For",
            "Proxy-Client-IP",
            "WL-Proxy-Client-IP",
            "HTTP_X_FORWARDED_FOR",
            "HTTP_X_FORWARDED",
            "HTTP_X_CLUSTER_CLIENT_IP",
            "HTTP_CLIENT_IP",
            "HTTP_FORWARDED_FOR",
            "HTTP_FORWARDED",
            "HTTP_VIA",
            "REMOTE_ADDR"
    };

	// IP 추출 메서드
    public String getClientIP()
    {
        if (RequestContextHolder.getRequestAttributes() == null) return "0.0.0.0";

        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        for (String header: IP_HEADER_CANDIDATES)
        {
            String ipList = request.getHeader(header);
            if (ipList != null && !ipList.isEmpty() && !"unknown".equalsIgnoreCase(ipList)) return ipList.split(",")[0];
        }

        return request.getRemoteAddr();
    }

    public IpService(@Value("${geoip.database.path}") String databasePath)
    {
        try
        {
            File database = new File(databasePath);
            this.dbReader = new DatabaseReader.Builder(database).build();
        }
        catch (Exception e)
        {
            throw new RuntimeException("GeoIP 데이터베이스 읽기 실패 : " + e.getMessage(), e);
        }
    }

	// IP를 바탕으로 국가명을 반환하는 메서드
    public String getCountryByIp(String ip)
    {
        if (isInternalIp(ip)) return "내부 아이피"; // 내부 IP 확인

        try
        {
            InetAddress ipAddress = InetAddress.getByName(ip);
            CountryResponse response = dbReader.country(ipAddress);
            return response.getCountry().getName();
        }
        catch (Exception e)
        {
            return "Unknown Country";
        }
    }

    private boolean isInternalIp(String ipAddress)
    {
        if ("127.0.0.1".equals(ipAddress)) return true; // IPv4 루프백
        if ("0:0:0:0:0:0:0:1".equals(ipAddress)) return true; // IPv6 루프백
        return ipAddress.startsWith("10.") || ipAddress.startsWith("192.168.") || (ipAddress.startsWith("172.") && isInRange(ipAddress)); // 사설 IP 범위 확인
    }

    // 두 번째 옥텟 범위 확인(16 ~ 31)
    private boolean isInRange(String ipAddress)
    {
        try
        {
            String[] parts = ipAddress.split("\\.");
            int secondOctet = Integer.parseInt(parts[1]);
            return secondOctet >= 16 && secondOctet <= 31;
        }
        catch (Exception e)
        {
            return false;
        }
    }
}

 

application.yml에 geoip 데이터베이스 위치 설정

geoip:
  database:
    path: /Users/.../GeoLite2-Country.mmdb

데이터베이스(.mmdb)가 위치한 경로를 설정해준다.

이렇게 해두면 @Value 애노테이션에서 DB의 위치를 인식한다.

 

서블릿 필터에 등록

모든 해외 요청에 대해서 IP를 기반으로  차단하려면 서블릿 필터에 등록해야 한다.

import com.seungwook.main.service.IpService;
import com.seungwook.main.service.LoginFailureListService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Slf4j
@Component
@RequiredArgsConstructor
public class IpBanFilter extends OncePerRequestFilter
{
    private final IpService ipService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException
    {
        String ip = ipService.getClientIP();
        String country = ipService.getCountryByIp(ip);
        String requestURI = request.getRequestURI(); // 요청 경로
        String method = request.getMethod(); // 요청 메서드

        if(country.equals("내부 아이피")) // 내부 아이피라면 다음 필터로 전달
        {
            filterChain.doFilter(request, response);
            return;
        }

        if(!country.equals("South Korea")) // 아이피가 한국이 아니라면
        {
            log.info("Access Rejected(Foreign IP) {}({}) / ({} -> {})", ip, country, method, requestURI);
            response.sendError(HttpServletResponse.SC_FORBIDDEN); // http 403 상태코드 전달
            return;
        }

        log.info("{}({}) / ({} -> {})", ip, country, method, requestURI);
        filterChain.doFilter(request, response); // 차단되지 않았다면, 다음 필터로 요청 전달
    }

    //정적 자원 요청은 필터링 제외
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request)
    {
        String path = request.getRequestURI();
        return path.startsWith("/css/") || path.startsWith("/js/") || path.startsWith("/images/") || path.startsWith("/favicon.ico");
    }
}

 

이렇게 설정하면

2025-01-16 21:18:49.905 [http-nio-10000-exec-4]  INFO com.seungwook.main.config.IpBanFilter - Access Rejected(Foreign IP) 54.83.136.80(United States) / (GET -> /)
2025-01-16 21:19:28.027 [http-nio-10000-exec-7]  INFO com.seungwook.main.config.IpBanFilter - Access Rejected(Foreign IP) 54.248.152.138(Japan) / (GET -> /login)

위와 같은 로그가 남으면서 해외 접근이 차단된다.

 

실제로 VPN을 이용해서 서버에 접근했지만 모두 차단되었다.

 

레퍼런스