Articles

로그인 기록의 IP 주소 옆에 대략적인 위치 보여주기

로그인 기록에 IP 주소만 있으면 내가 접속한 것인지 알기 어려워요. 키 없이 쓰는 공개 서비스로 위치를 알아내고, 내부 네트워크 주소는 묻지 않고, 한 번 알아낸 주소는 기기에 적어 두는 방법을 정리했어요.

WhoIsLoafing의 내 정보 화면에는 로그인한 기기와 로그인 기록이 나와요. 각 기록에는 IP 주소가 적혀 있어요. 그런데 211.234.xxx.xxx 같은 숫자를 보고 "이건 내가 집에서 접속한 거구나"라고 알아볼 수 있는 사람은 거의 없어요.

그래서 IP 주소 옆에 "Seoul, 대한민국"처럼 대략적인 위치를 함께 보여주기로 했어요. 낯선 나라에서 로그인한 기록이 있으면 바로 눈에 띄어요.

위치는 공개 서비스에 물어봐요

IP 주소로 위치를 알아내는 공개 서비스는 여럿 있어요. 그중 키 없이 쓸 수 있는 ipwho.is를 골랐어요. 화면에서 바로 부르는 코드라, 키가 필요한 서비스를 쓰면 그 키가 브라우저에 그대로 드러나요.

request = fetch(`${LOOKUP_URL}${encodeURIComponent(ip)}`)
  .then((res) => (res.ok ? (res.json() as Promise<Lookup>) : null))
  .then((data) => {
    if (!data || data.success === false) return null
    const place = [data.city || data.region, countryName(data.country_code, data.country)].filter(Boolean).join(', ')
    if (!place) return null
    store(ip, place)
    return place
  })
  .catch(() => null)

도시 이름이 없으면 지역 이름을 쓰고, 그것도 없으면 나라만 보여줘요. 하나도 알아내지 못하면 위치 없이 IP 주소만 보여줘요. 위치는 덤이라, 실패해도 오류를 띄우지 않아요.

나라 이름은 브라우저가 한국어로

서비스는 나라 이름을 영어로 돌려줘요. "South Korea"보다 "대한민국"이 자연스러워서, 나라 코드를 받아 브라우저의 Intl.DisplayNames로 한국어 이름을 얻어요.

function countryName(code: string | undefined, fallback: string | undefined): string {
  try {
    countryNames ??= new Intl.DisplayNames('ko', { type: 'region' })
    return (code && countryNames.of(code)) || fallback || ''
  } catch {
    return fallback ?? ''
  }
}

나라 이름 표를 따로 들고 있을 필요가 없어요. 이 기능을 지원하지 않는 오래된 브라우저에서는 서비스가 준 영어 이름을 그대로 써요.

내부 네트워크 주소는 묻지 않아요

192.168.0.10이나 10.0.0.5 같은 주소는 집이나 회사 안에서만 쓰는 주소예요. 세상에 같은 주소가 수없이 많아서 위치를 알 수 없어요. 개발 중에 내 컴퓨터에서 접속하면 127.0.0.1이 찍히기도 해요.

이런 주소는 서비스에 묻지 않고 바로 "내부 네트워크"라고 보여줘요.

const PRIVATE_V4 = /^(?:127\.|10\.|192\.168\.|169\.254\.|172\.(?:1[6-9]|2\d|3[01])\.)/
const PRIVATE_V6 = /^(?:::1$|(?:0:){7}1$|f[cd][0-9a-f]{2}:|fe[89ab][0-9a-f]:)/i

function isPrivateIp(ip: string): boolean {
  const value = ip.replace(/^::ffff:/i, '')
  return PRIVATE_V4.test(value) || PRIVATE_V6.test(value)
}

172.로 시작하는 주소는 전부가 내부 주소가 아니에요. 172.16부터 172.31까지만 내부 주소라서, 정규식에서 그 범위만 걸러요.

::ffff:를 떼는 부분도 있어요. 서버에 따라 IPv4 주소를 IPv6 모양으로 감싸서 ::ffff:192.168.0.10처럼 기록하기도 해요. 앞부분을 떼고 보면 평범한 IPv4 주소예요.

한 번 알아낸 주소는 적어 둬요

로그인 기록에는 같은 IP 주소가 여러 번 나와요. 집에서 매일 접속하면 같은 주소가 줄줄이 찍혀요. 그때마다 서비스에 물으면 낭비이고, 무료 서비스의 요청 한도에도 금방 닿아요.

그래서 두 가지를 했어요.

첫째, 같은 주소를 화면 여러 곳에서 동시에 물어도 요청은 한 번만 보내요. 진행 중인 요청을 주소별로 들고 있다가 같은 것을 돌려줘요.

const pending = new Map<string, Promise<string | null>>()

둘째, 알아낸 위치는 기기의 localStorage에 적어 둬요. 다음에 내 정보 화면을 열면 묻지 않고 바로 보여줘요. 다만 끝없이 쌓이지 않도록 200개까지만 두고, 넘으면 오래된 것부터 버려요.

function store(ip: string, place: string) {
  try {
    const entries = Object.entries({ ...readStored(), [ip]: place }).slice(-STORED_LIMIT)
    localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)))
  } catch {
    return
  }
}

자바스크립트 객체는 숫자처럼 생기지 않은 키를 넣은 순서대로 기억해요. IP 주소는 점이 들어 있어서 순서가 유지되고, 뒤에서 200개를 자르면 최근에 넣은 것만 남아요.

화면은 아는 만큼 먼저

리액트 훅의 처음 값은 저장해 둔 위치에서 바로 읽어요. 이미 아는 주소면 화면이 처음 그려질 때부터 위치가 보이고, 모르는 주소만 잠깐 비어 있다가 채워져요.

const [place, setPlace] = useState<string | null>(() => (ip ? (isPrivateIp(ip) ? LOCAL_PLACE : (readStored()[ip] ?? null)) : null))

요청이 끝나기 전에 다른 화면으로 넘어가면 결과를 버려요. 사라진 화면의 상태를 바꾸지 않게 하려는 것이에요.

한계

  • IP 주소의 위치는 통신사가 주소를 나눠 주는 곳 기준이에요. 실제로 있는 곳과 다른 도시가 나오기도 해요. 그래서 도시와 나라까지만 보여주고 더 자세히 적지 않아요.
  • 휴대폰 데이터나 VPN을 쓰면 전혀 다른 지역이 나올 수 있어요.
  • 위치를 알아내려면 IP 주소를 외부 서비스에 보내야 해요. 그래서 이 기능은 본인의 로그인 기록과 관리자 화면에서만 써요.

정리

  • 화면에서 바로 부르는 외부 서비스는 키가 필요 없는 것을 골라요.
  • 내부 네트워크 주소는 위치를 알 수 없으니 묻지 않아요.
  • 같은 주소는 한 번만 묻고, 알아낸 위치는 개수를 정해서 기기에 적어 둬요.
  • 나라 이름은 브라우저의 Intl.DisplayNames로 바꾸면 표가 필요 없어요.
  • 위치는 덤이니, 알아내지 못해도 오류 없이 IP 주소만 보여줘요.
다음 글 팀 프로젝트 기여도를 대화로 풀어 주는 서비스를 만든 이유