손호영

oshi@log

밴드 성지·라이브 이벤트·커뮤니티를 한곳에서 다루는 위치 기반 성지순례 서비스의 백엔드입니다. Kotlin과 Spring Boot 3로 만들고 Spring Modulith로 도메인을 6개 모듈로 나눴습니다. 서버는 베타 운영 중이고 기존 VM에서 OCI 춘천 OKE로 옮겼습니다. 운영 콘솔(React)도 같은 레포에서 관리하고 있습니다.

상태
모바일 앱 베타 테스트 중
기간
2025.08 – 현재
역할
백엔드 1인 개발
기술
Kotlin, Spring Boot 3, Spring Modulith, PostgreSQL · PostGIS, Hibernate Spatial, Flyway, Redis, Caffeine, Resilience4j, JWT · OAuth2, Cloudflare R2, Testcontainers, Kover · Detekt · Ktlint, React · TypeScript (운영 콘솔), OCI DevOps · OCIR, OKE · Argo CD
도메인 모듈
6
테스트
1,952
커버리지 게이트
서비스 계층 80% 강제
oshi@log 아키텍처 다이어그램
시스템 아키텍처. 누르면 원본 SVG가 새 창에서 열립니다.

주요 성과

  • 베타 서비스 중이던 서버를 OCI 춘천 OKE로 옮겼습니다. OCI DevOps가 빌드하고 Argo CD가 dev 브랜치는 개발 서버로, release 브랜치는 운영 서버로 배포합니다
  • PostGIS·Redis를 Testcontainers로 띄우는 통합 테스트 61개가 CI에서 돌고 Kover 게이트가 핵심 서비스 계층 80% 미만일 때 빌드를 차단합니다
  • 모듈 경계 기준선 3종에 없는 모듈 간 의존성이 들어오면 아키텍처 테스트가 CI에서 실패합니다
  • Flyway 마이그레이션 105개를 베이스라인 5개 파일로 다시 세우면서 운영 스키마는 건드리지 않았습니다
  • 위치 인증 요청은 JWE 복호화, 기기 등록 키 JWS 검증, 거리·이동 속도·방문 횟수 규칙을 차례로 거칩니다

담당 작업

  • 기능을 identity·place·oshikatsu·community·gateway·platform 6개 모듈로 나눴습니다. 모듈 정의는 모듈마다 둔 Kotlin ModuleMetadata에 두고 아키텍처 테스트가 구조 변경을 잡아 줍니다. 이전에는 도메인이 평면으로 흩어져 있어서 어느 쪽이 어느 쪽을 쓰는지 읽기 어려웠습니다.
  • 모듈 경계는 기준선 파일 세 개로 고정해 뒀습니다. 모듈 간 리포지토리 import 304줄, 진입점 의존성 266줄, platform 도메인 import 21줄을 목록으로 박아 뒀습니다. 목록에 없는 의존성이 생기면 아키텍처 테스트가 실패합니다. 평면 구조일 때 생긴 모듈 간 참조는 한 번에 지우지 않고 늘지 않게 막는 쪽을 골랐습니다.
  • PostgreSQL과 PostGIS에 장소·지역을 저장하고 Hibernate Spatial로 주변 검색과 거리 계산을 합니다. 성지 체크인은 거리만 보지 않습니다. 직전 인증과의 이동 속도, 1시간 내 방문한 장소 수까지 서버에서 확인해 규칙에 걸리면 거부하거나 플래그를 답니다.
  • 읽기가 많은 장소·이벤트 목록은 Caffeine에, 세션·토큰 차단 목록·요청 횟수처럼 인스턴스가 공유해야 하는 값은 Redis에 두고 있습니다. Redis 조회는 Resilience4j 서킷 브레이커와 재시도로 감싸 뒀습니다. 실패하면 null로 떨어뜨려 호출 쪽이 캐시 미스처럼 처리하게 했습니다.
  • Google·Apple OAuth2와 이메일 인증을 구현했습니다. 위치 인증 토큰은 JWE로 받아 복호화하고 안에 든 JWS를 기기별 등록 공개 키로 검증합니다. 허용 서명 알고리즘은 RS256 하나입니다.
  • 게시글·댓글·신고·모더레이션을 만들고 미디어를 업로드할 때 WebP로 변환하는 기능을 붙였습니다. 응답 문구는 Accept-Language를 보고 한국어와 일본어로 나누고 운영 콘솔 API는 한국어로 고정합니다.
  • 테스트는 단위 1,891개와 통합 61개입니다. PostGIS와 Redis는 Testcontainers로 띄우고 Kover 게이트는 핵심 비즈니스 로직(서비스 계층) 80% 미만 시 빌드를 차단합니다. Detekt·Ktlint도 같은 CI에서 함께 확인합니다.
  • CI는 OCI DevOps에서 돌고 Testcontainers 통합 테스트와 Kover 커버리지 검증을 통과해야 arm64 이미지를 만듭니다. 이미지는 OCIR에 올라가고 앱 레포의 이미지 포인터를 Argo CD가 읽어 dev·prod에 반영합니다. release 빌드의 소스가 dev에서 확인한 소스와 같으면 dev 이미지를 그대로 운영에 씁니다.
  • 운영 콘솔은 React와 TypeScript로 만들고 dev.admin.oshilog.org와 admin.oshilog.org로 나눠 배포하고 있습니다. dev 승인을 prod 승인으로 확대하지 않습니다.

문제 해결 사례

제목을 누르면 접근과 결과가 펼쳐집니다.

필터를 비워 호출할 때만 터지던 네이티브 쿼리, 같은 형태를 전수 조사

큐레이션 데이터를 올린 DB에 앱을 띄우고 OpenAPI가 선언한 GET 276개를 실제로 호출해 봤습니다. 통합 장소 검색에서 키워드만 주면 항상 could not determine data type of parameter가 났습니다. (:param IS NULL OR ...) 형태는 PostgreSQL에 파라미터 타입을 추론할 근거를 주지 않는데 값을 넘기면 드라이버가 타입을 공급하기 때문에 값을 채워 도는 테스트에서는 전부 멀쩡해 보입니다.

큐레이션 데이터를 올린 DB에 앱을 띄우고 OpenAPI가 선언한 GET 276개를 실제로 호출해 봤습니다. 통합 장소 검색에서 키워드만 주면 항상 could not determine data type of parameter가 났습니다. (:param IS NULL OR ...) 형태는 PostgreSQL에 파라미터 타입을 추론할 근거를 주지 않는데 값을 넘기면 드라이버가 타입을 공급하기 때문에 값을 채워 도는 테스트에서는 전부 멀쩡해 보입니다.

접근

증상 하나만 고치지 않고 같은 형태를 전수 조사해 리포지토리 6개 41군데를 CAST로 닫았습니다. 파라미터 21개가 전부 nullable 선언이라 모두 도달 가능한 위험이었습니다.

이 프로젝트에는 이미 같은 실패를 겪고 넣어 둔 우회책이 있었습니다. 알림 아웃박스 리포지토리가 :xFilterEnabled 플래그와 구체 값을 함께 넘기는 방식입니다. 그 방식을 나머지로 퍼뜨리는 대신 CAST를 골랐습니다. 플래그 방식은 파라미터 목록이 두 배가 됩니다.

같이 나온 다른 500은 사진이 0장인 장소에서 SUM(CASE ...)이 NULL을 돌려주는데 프로젝션이 non-null Long을 선언한 것이었습니다. 프로젝션을 nullable로 바꾸는 대신 쿼리에서 COALESCE로 막았습니다. 호출자는 숫자를 원하고 숫자가 있음을 보장할 수 있는 곳은 DB뿐입니다.

결과

회귀 테스트 두 개를 붙이고 수정 전 코드에서 실패하는지도 확인했습니다. CAST를 되돌리면 5건이 실패합니다. 둘 다 실제 PostgreSQL이 필요합니다. 목 리포지토리는 DB를 거치지 않아 이 오류가 재현되지 않기 때문입니다.

밴 시스템이 방문자 대신 앞단 프록시를 차단하고 있었다

nginx가 CF-Connecting-IP를 신뢰하지 않아 $remote_addr가 요청을 중계한 Cloudflare 엣지 주소였습니다. 그 주소를 키로 삼는 모든 것이 틀린 주소를 읽었고 하루 사이에 엣지 세 개가 차단됐습니다. 그 엣지 뒤의 방문자 전부가 밴이 풀릴 때까지 444를 받았고 저도 그중 하나였습니다. 화이트리스트 두 개도 같은 이유로 장식이었습니다.

nginx가 CF-Connecting-IP를 신뢰하지 않아 $remote_addr가 요청을 중계한 Cloudflare 엣지 주소였습니다. 그 주소를 키로 삼는 모든 것이 틀린 주소를 읽었고 하루 사이에 엣지 세 개가 차단됐습니다. 그 엣지 뒤의 방문자 전부가 밴이 풀릴 때까지 444를 받았고 저도 그중 하나였습니다. 화이트리스트 두 개도 같은 이유로 장식이었습니다.

접근

Cloudflare 공개 대역에 set_real_ip_from과 real_ip_header CF-Connecting-IP를 걸어 $remote_addr를 방문자로 되돌렸습니다. X-Forwarded-For에는 클라이언트가 보낸 값이 앞에 남을 수 있어 Cloudflare가 항상 덮어쓰는 CF-Connecting-IP를 골랐습니다. 대역 밖에서 온 요청은 nginx가 peer 주소를 그대로 두기 때문에 오리진을 직접 찔러도 헤더를 위조할 수 없습니다.

밴이 스스로를 갱신하는 문제도 있었습니다. 이미 차단된 주소의 노크에도 nginx가 444로 답하고 스캐너 jail이 그 444를 다시 세기 때문에 30일 밴이 영원히 만료되지 않았습니다. 액세스 로그에 blk=1을 덧붙여 필터가 그 줄을 무시하게 했습니다.

밴 기간은 30일 고정에서 하루부터 배로 늘려 최대 30일로 바꿨고 해제 시각은 5분까지 흩뜨렸습니다. 고정 30일은 오탐을 스캐너와 똑같이 처벌하고 운영자는 누가 접속이 안 된다고 알려 줘야만 알게 됩니다.

결과

일회용 컨테이너로 호스트에서 검증했습니다. 22개 대역이 들어간 nginx 설정이 통과했고 fail2ban-regex가 blk=1 줄은 무시하고 실제 프로브 줄만 잡는 것을 확인했습니다.

/ 하나만 요청한 인터넷 센서스가 첫 히트에 밴되던 구조

서비스하지 않는 Host로 오는 요청에 nginx가 444로 답하고 스캐너 jail은 444를 첫 히트에 차단합니다. 그래서 / 하나만 요청해도 바로 밴되는데, 인터넷 전체를 훑는 스캐너가 서버를 확인할 때 보내는 요청이 바로 이것입니다. 이 호스트 444의 84%가 맨 /였습니다. 게다가 bad-agent 목록에 curl/, okhttp, python-requests 같은 일반 HTTP 런타임이 들어 있어, 모바일 SDK와 CI 점검은 물론 이 레포 런북의 헬스 체크 명령을 실행한 주소까지 차단됐습니다.

서비스하지 않는 Host로 오는 요청에 nginx가 444로 답하고 스캐너 jail은 444를 첫 히트에 차단합니다. 그래서 / 하나만 요청해도 바로 밴되는데, 인터넷 전체를 훑는 스캐너가 서버를 확인할 때 보내는 요청이 바로 이것입니다. 이 호스트 444의 84%가 맨 /였습니다. 게다가 bad-agent 목록에 curl/, okhttp, python-requests 같은 일반 HTTP 런타임이 들어 있어, 모바일 SDK와 CI 점검은 물론 이 레포 런북의 헬스 체크 명령을 실행한 주소까지 차단됐습니다.

접근

일반 HTTP 런타임과 빈 UA를 목록에서 뺐습니다. sqlmap, nikto, nuclei, masscan 같은 도구는 남겼습니다. 실수로 보내는 방문자가 없어 오탐이 없기 때문입니다. /와 함께 favicon.ico, robots.txt도 무시 대상에 넣었습니다. /만 무시하면 브라우저가 첫 화면을 열고 곧바로 아이콘을 요청할 때 그다음 줄에서 그 방문자를 밴합니다. 401도 실패 패턴에서 뺐습니다. 이 오리진의 401은 자격증명 공격이 아니라 만료 토큰 폴링과 웹소켓 재연결입니다.

기각한 안이 셋입니다. UA 거절은 두고 밴만 빼는 안은, server 레벨 조건이 location보다 먼저 평가되어 스캐너 경로를 요청한 나쁜 UA까지 무시되므로 경로 증거가 사라집니다. 런북만 고쳐 UA를 위장하는 안은 증상만 옮기고 모바일 SDK와 서드파티 연동은 그대로 막힙니다. 임계값 상향은 401을 세는 것 자체가 틀린 상태에서 진짜 열거 탐지만 둔하게 만듭니다.

결과

액세스 로그 262,600줄 기준으로 목록에서 뺀 UA의 트래픽은 요청의 1.3%입니다. 정책이 되돌아오지 않게 UA 정책 테스트를 빌드 단계에 등록했습니다.

CI는 초록인데 매일 01시에 실패하던 야간 잡

적대적 검수에서 결함 세 건이 나왔는데 셋 다 CI 게이트가 초록인 채로 존재했습니다. 가장 큰 결함은 삭제된 테이블이었습니다. 일별 집계 잡이 매일 01:00 KST에 넣는 테이블인데 앞선 스키마 작업에서 도달 불가로 판정돼 삭제됐습니다. 매일 밤 relation does not exist로 실패하고 있었고 그 결과를 읽는 코드는 0곳이었습니다.

적대적 검수에서 결함 세 건이 나왔는데 셋 다 CI 게이트가 초록인 채로 존재했습니다. 가장 큰 결함은 삭제된 테이블이었습니다. 일별 집계 잡이 매일 01:00 KST에 넣는 테이블인데 앞선 스키마 작업에서 도달 불가로 판정돼 삭제됐습니다. 매일 밤 relation does not exist로 실패하고 있었고 그 결과를 읽는 코드는 0곳이었습니다.

접근

왜 두 가드가 못 잡았는지부터 봤습니다. 테이블 존재 검사는 @Table과 @Query(nativeQuery = true)만 훑는데 문제의 SQL은 jdbcTemplate 문자열 리터럴이라 어느 쪽에도 안 걸립니다. "네이티브 SQL 111건 수식 완료"라는 기존 보고도 트리 전체 중 스윕이 만든 문장만 두고 한 진술이었습니다.

잡 105줄을 지웠습니다. 테이블이 없고 읽는 곳도 없으니 남길 이유가 없습니다. 나머지 두 건도 뿌리가 같았습니다. 가드의 정규식이 패키지 경로를 하드코딩해서 그 경로 밖 선언은 import든 별칭이든 완전수식 참조든 어떤 탐지 경로로도 표현되지 않습니다.

결과

이번 검수에서 가드가 자기 정규식으로 표현할 수 없는 경우를 보고하지 못한다는 것을 확인했습니다. 기준선이 있다고 해서 기준선이 그 코드를 볼 수 있는 것은 아닙니다.

필드가 늘어나면 조용히 무효화되던 캐시 키

장소 댓글 조회의 캐시 키가 필터 객체의 toString()을 쓰고 있었습니다. Kotlin data class라 컴파일러가 만든 toString()에 모든 필드 이름이 들어갑니다. 필터 필드가 6개에서 14개 넘게 늘어나는 동안 필드가 추가될 때마다 키 포맷이 바뀌었습니다. 기존 엔트리는 지워지지 못한 채 TTL까지 남고 새 요청은 전부 미스가 되고 기본값이 null인 필드가 붙으면 의미가 같은 질의도 키가 달라졌습니다.

장소 댓글 조회의 캐시 키가 필터 객체의 toString()을 쓰고 있었습니다. Kotlin data class라 컴파일러가 만든 toString()에 모든 필드 이름이 들어갑니다. 필터 필드가 6개에서 14개 넘게 늘어나는 동안 필드가 추가될 때마다 키 포맷이 바뀌었습니다. 기존 엔트리는 지워지지 못한 채 TTL까지 남고 새 요청은 전부 미스가 되고 기본값이 null인 필드가 붙으면 의미가 같은 질의도 키가 달라졌습니다.

접근

키 식을 현재 필드를 전부 명시해 나열하는 형태로 바꾸고 새 필드를 추가하면 키 식에도 넣도록 테스트로 강제했습니다. 필터를 JSON으로 만들어 해시를 키로 쓰는 안은 길이는 안정되지만 로그에서 키를 눈으로 읽을 수 없고 바뀐 필드만 골라 넣는 안은 복잡하고 실수하기 쉬워 둘 다 기각했습니다. 장소 ID 접두사는 접두사 단위 무효화가 계속 동작하도록 그대로 뒀습니다.

결과

키 길이가 80자 안팎에서 250자 안팎으로 늘었습니다. 옛 포맷 엔트리가 TTL까지 남는 1회성 비용은 받아들였습니다.

레이트 리미터가 스스로 장애 원인이 되지 않게

요청 제한이 Redis 문자열 카운터와 TTL을 쓰는 고정 윈도우라 윈도우가 바뀌는 경계에서 버스트가 통과했습니다. 게다가 Redis가 죽으면 /actuator/health/liveness가 500을 냈습니다. 전역 IP 버킷이 actuator 경로 분기보다 먼저 평가돼 헬스 프로브까지 Redis를 부르고 있었고 연결 실패 예외가 필터 체인을 타고 올라가 모든 요청이 500에 15초 타임아웃이 됐습니다.

요청 제한이 Redis 문자열 카운터와 TTL을 쓰는 고정 윈도우라 윈도우가 바뀌는 경계에서 버스트가 통과했습니다. 게다가 Redis가 죽으면 /actuator/health/liveness가 500을 냈습니다. 전역 IP 버킷이 actuator 경로 분기보다 먼저 평가돼 헬스 프로브까지 Redis를 부르고 있었고 연결 실패 예외가 필터 체인을 타고 올라가 모든 요청이 500에 15초 타임아웃이 됐습니다.

접근

소비 경로를 sorted set 기반 슬라이딩 윈도우로 바꿨습니다. Lua 스크립트 한 번 실행 안에서 윈도우 밖 이벤트를 지우고 현재 수를 세고 제한 미만일 때만 추가하므로 경합에서도 원자적입니다. 기존 시그니처를 유지해 호출부는 건드리지 않았습니다.

Redis 장애 쪽은 actuator 검사를 앞으로 옮기고 소비 호출을 fail-open으로 바꿨습니다. 모든 소비 호출을 감싸 연결 실패와 데이터 접근 예외를 잡은 뒤 경고 로그를 남기고 통과시킵니다. 텍스트 핑거프린트 스팸 가드도 같은 기준입니다. 도배 탐지율이 잠깐 떨어지는 쪽이 서비스가 막히는 쪽보다 낫다고 봤습니다.

결과

대가로 윈도우 안의 요청 이벤트를 들고 있어야 해서 고정 윈도우보다 Redis 메모리를 더 씁니다.

배포해야만 바꿀 수 있던 칭호 규칙을 DB로

업적에서 칭호를 자동으로 주는 로직이 companion object 맵에 하드코딩돼 있어 운영 중에 규칙을 바꾸려면 배포가 필요했습니다. 조건도 업적 누적 횟수 하나뿐이라 방문 횟수나 특정 장소 조합 같은 것을 걸 수 없었습니다.

업적에서 칭호를 자동으로 주는 로직이 companion object 맵에 하드코딩돼 있어 운영 중에 규칙을 바꾸려면 배포가 필요했습니다. 조건도 업적 누적 횟수 하나뿐이라 방문 횟수나 특정 장소 조합 같은 것을 걸 수 없었습니다.

접근

규칙을 테이블로 옮기고 하드코딩 맵을 지웠습니다. 읽기에는 캐시를, 쓰기에는 무효화를 붙이고 어드민 CRUD를 열었습니다. 커스텀 규칙은 네 가지입니다. 방문·라이브 누적이 임계값 이상인 경우와, 지정한 장소·라이브 전체를 채운 경우입니다. 평가는 방문·라이브 이벤트가 발행될 때 리스너가 받아서 합니다.

규칙 항목을 JSON 배열 한 컬럼에 담는 안은 조인도 IN 절 배치 조회도 못 해서 기각하고 자식 테이블로 뒀습니다. 룰 서비스를 따로 떼는 안은 지금 규모에서 과합니다. 활성 칭호는 프로젝트마다 하나이고 전역 칭호와 프로젝트 칭호를 한 테이블에서 다루려고 PK를 사용자와 프로젝트 조합으로 잡았습니다.

결과

배포 없이 규칙을 추가하고 바꾸고 끌 수 있게 됐습니다. 대신 방문마다 이벤트가 발행돼 평가 빈도가 늘어 캐시와 인덱스로 완화했습니다. 항목이 0건인 집합 규칙이 즉시 부여되는 엣지 케이스는 부채로 남겨 뒀습니다.

무중단 인덱스 생성이 기동을 멈춰 세운 이유

방문 이벤트 테이블에 CREATE INDEX CONCURRENTLY를 쓰는 마이그레이션에서 기동 중 Flyway가 idle in transaction과 virtualxid 락 대기에 머물렀습니다. Flyway의 트랜잭션 advisory lock 동작이 동시 인덱스 생성과 맞지 않았습니다.

방문 이벤트 테이블에 CREATE INDEX CONCURRENTLY를 쓰는 마이그레이션에서 기동 중 Flyway가 idle in transaction과 virtualxid 락 대기에 머물렀습니다. Flyway의 트랜잭션 advisory lock 동작이 동시 인덱스 생성과 맞지 않았습니다.

접근

마이그레이션 SQL은 그대로 두고 Flyway 설정에서 트랜잭션 락을 끄는 쪽을 골랐습니다. 대용량 테이블에서 블로킹이 적은 인덱스 생성 전략을 포기하고 싶지 않았습니다. 대가로 PostgreSQL 마이그레이션 락이 트랜잭션 기반에서 세션 기반으로 바뀝니다.

JVM 플래그 두 개가 기동을 막아 되돌린 튜닝

서버가 두 환경에서 돕니다. 2 vCPU·8GB 인스턴스(힙 3GB)와 ARM64 4 vCPU·10GB 인스턴스(힙 7GB)입니다. 확인한 문제가 셋이었습니다. 3GB 힙에서 IHOP 35%는 1GB 근처에서 마크를 시작해 2 vCPU에 불필요한 사이클을 돌렸고 다른 환경은 JAVA_OPTS가 기본 옵션을 덧붙이지 않고 통째로 덮어써서 Metaspace 상한이 사라져 있었고 커넥션 풀에 keepalive가 없어 게이트웨이가 유휴 TCP를 끊은 뒤 연결을 못 얻는 오류가 났습니다.

서버가 두 환경에서 돕니다. 2 vCPU·8GB 인스턴스(힙 3GB)와 ARM64 4 vCPU·10GB 인스턴스(힙 7GB)입니다. 확인한 문제가 셋이었습니다. 3GB 힙에서 IHOP 35%는 1GB 근처에서 마크를 시작해 2 vCPU에 불필요한 사이클을 돌렸고 다른 환경은 JAVA_OPTS가 기본 옵션을 덧붙이지 않고 통째로 덮어써서 Metaspace 상한이 사라져 있었고 커넥션 풀에 keepalive가 없어 게이트웨이가 유휴 TCP를 끊은 뒤 연결을 못 얻는 오류가 났습니다.

접근

IHOP을 기본값으로 되돌리고 예약 비율을 올렸습니다. Metaspace 상한을 복구하고 커넥션 풀에 keepalive를 넣었습니다. ZGC와 Shenandoah는 2 vCPU에서 오버헤드가 이득보다 크고 IHOP을 더 올리는 안은 3GB 힙에서 너무 늦어 버스트 때 full GC 위험이 있어 모두 기각했습니다.

같이 넣었던 새 세대 크기 플래그 두 개는 되돌렸습니다. 배포된 JDK 버전에서 experimental 취급이라 JVM이 아예 기동을 거부했습니다. 이제 튜닝 플래그를 넣을 때는 그 버전에서 실험 옵션인지부터 확인합니다.

결과

GC 지표 전후 비교는 재지 않았습니다. 되돌린 기록과, 기동을 막은 플래그가 무엇이었는지만 남아 있습니다.

인증 API가 500을 내던 geography 바인딩, 우회를 걷어내고 원인으로

방문 인증 저장이 JDBC 파라미터 바인딩 오류로 500을 냈습니다. JTS Point를 PostGIS geography(Point, 4326) 컬럼에 넣는 경로가 깨져 있었습니다.

방문 인증 저장이 JDBC 파라미터 바인딩 오류로 500을 냈습니다. JTS Point를 PostGIS geography(Point, 4326) 컬럼에 넣는 경로가 깨져 있었습니다.

접근

처음에는 WKT 문자열 필드를 따로 두고 엔티티를 저장할 때 동기화하는 우회를 썼습니다. 이후 PostGIS 방언과 hibernate-spatial 버전을 Hibernate ORM에 맞추는 쪽으로 되돌렸습니다. 우회 필드와 동기화 훅을 지워 엔티티가 geography 컬럼을 그대로 쓰게 했습니다.

결과

저장과 거리 계산이 같은 DB 타입 기준으로 돌아갑니다. 방언이나 드라이버 버전이 바뀌면 같은 문제가 돌아올 수 있어 인증 저장 경로를 지나는 테스트를 남겼습니다.

위치 토큰을 암호화와 기기 키 서명으로 나눠 받기

체크인은 앱이 보낸 좌표와 시각을 근거로 포인트와 도감을 주는 기능입니다. 서버가 그 값을 그대로 믿으면 요청 본문만 고쳐도 가 본 적 없는 장소를 인증할 수 있습니다.

체크인은 앱이 보낸 좌표와 시각을 근거로 포인트와 도감을 주는 기능입니다. 서버가 그 값을 그대로 믿으면 요청 본문만 고쳐도 가 본 적 없는 장소를 인증할 수 있습니다.

접근

위치 페이로드는 JWE로 암호화해 받습니다. JWE는 암호문 자체에 무결성 검사가 들어 있어 중간에서 값을 바꾸면 복호화 단계에서 실패합니다. 좌표가 개인정보라 경로와 로그에 평문이 남지 않는 것도 필요했습니다.

다만 복호화에 성공해도 그 토큰을 등록된 기기가 만들었는지는 알 수 없습니다. 그래서 JWE 안에 든 JWS를 사용자·기기별 등록 공개 키로 검증해 발신자를 확인합니다. 좌표 기밀성은 암호화가 맡습니다.

키는 기기마다 만들고 공개 키만 서버에 등록합니다. JWK나 PEM으로 받아 PEM으로 저장하고 헤더의 kid로 찾습니다. 허용 알고리즘은 RS256이고 사용자당 활성 키 수에 상한이 있습니다. 클레임만 담긴 구버전 토큰은 jwt.location.require-jws를 켜기 전까지만 받았습니다.

결과

좌표를 바꾸려면 기기에 등록된 서명 키가 필요합니다. 키 회전과 폐기는 kid와 last_used_at으로 기기 단위로 처리합니다.

토큰 검증은 기기 확인까지이고, 좌표 자체는 서버에서 이동 속도와 방문 간격으로 다시 봅니다. 기기를 장악하면 서명까지 다시 할 수 있어서 복호화한 뒤에도 거리·이동 속도·방문 횟수 규칙을 따로 확인하고 걸린 요청을 거부하거나 플래그로 남깁니다.

신칸센 탄 사람과 순간이동 위조를 가르는 거리 구간별 속도 상한

이동 속도로 위치 조작을 거르는데 임계값을 하나만 두면 신칸센이나 국내선을 탄 사용자가 걸립니다. 상한을 낮추면 장거리 이동 사용자가 막히고 높이면 위조를 놓칩니다.

이동 속도로 위치 조작을 거르는데 임계값을 하나만 두면 신칸센이나 국내선을 탄 사용자가 걸립니다. 상한을 낮추면 장거리 이동 사용자가 막히고 높이면 위조를 놓칩니다.

접근

두 인증 사이의 하버사인 거리를 세 구간으로 나눠 상한을 다르게 줬습니다. 50km 미만은 초당 50m(도로·재래선), 50~500km는 초당 100m(신칸센), 500km 이상은 초당 300m(국내선)입니다.

속도 외에도 accuracy가 0 이하인 값은 거부하고 1시간 안에 서로 다른 장소 100곳 이상이면 RAPID_TRAVERSAL 플래그를 답니다. 단말이 보고하는 모의 위치 여부도 확인하되 iOS가 빈 문자열을 보내는 경우는 없는 값으로 처리합니다. 전체 검사는 기능 플래그로 끌 수 있습니다.

결과

구간 값은 실제 교통수단의 속도를 보고 정한 휴리스틱입니다. 수천 km를 몇 분 만에 이동하는 요청은 어느 구간에서도 상한을 넘습니다.

지금은 걸린 요청을 플래그로 남겨 나중에 임계값을 조정할 수 있게 해 둔 상태입니다.

되돌리기를 이전 값 덮어쓰기 대신 변경 내역 재생으로

관리자가 장소·유닛·라이브 정보를 고치다 잘못 저장하면 되돌려야 합니다. 직전 값만 들고 있으면 여러 번 고친 뒤에는 원하는 시점으로 돌아갈 수 없습니다.

관리자가 장소·유닛·라이브 정보를 고치다 잘못 저장하면 되돌려야 합니다. 직전 값만 들고 있으면 여러 번 고친 뒤에는 원하는 시점으로 돌아갈 수 없습니다.

접근

필드 단위 변경 내역을 JSONB로 남깁니다. 되돌릴 때는 저장된 이전 값을 덮어쓰지 않습니다. 대상 리비전까지의 변경 내역을 순서대로 재생해 그 시점 상태를 다시 만듭니다. 중간에 여러 번 고친 필드도 그 시점 값으로 복원됩니다. 롤백 자체도 새 리비전으로 남깁니다.

결과

엔티티에 MERGE 이력이 있으면 롤백을 막습니다. MERGE 리비전 자체만 막으면 그 이전 리비전으로 되돌아가 병합 전 상태처럼 보이게 만들 수 있습니다. 그래서 이력에 MERGE가 한 번이라도 있으면 전체를 차단합니다.

푸시와 피드 갱신을 한 테이블에서 각자 재시도하게

푸시 발송과 피드 후보 갱신은 실패해도 요청을 막으면 안 되는 작업입니다. 소비자가 둘인데 한 테이블을 쓰면 한쪽이 다른 쪽 행을 집어 갈 수 있습니다. 배치 중간에 프로세스가 죽으면 처리 중이던 행이 그대로 남습니다.

푸시 발송과 피드 후보 갱신은 실패해도 요청을 막으면 안 되는 작업입니다. 소비자가 둘인데 한 테이블을 쓰면 한쪽이 다른 쪽 행을 집어 갈 수 있습니다. 배치 중간에 프로세스가 죽으면 처리 중이던 행이 그대로 남습니다.

접근

나중에 처리해도 되는 작업을 아웃박스 테이블에 쌓고 kind 컬럼으로 생산자를 구분해 조회 쿼리가 자기 kind만 집어 가게 했습니다.

행 하나를 처리할 때마다 REQUIRES_NEW로 트랜잭션을 새로 열어 한 건이 실패해도 같은 배치의 나머지가 함께 롤백되지 않게 했습니다. 재시도는 지수 백오프로 늘리되 상한 3,600초에서 멈추고 한도를 넘으면 DEAD로 보냅니다.

프로세스가 처리 중에 죽으면 그 행이 PROCESSING에 갇히기 때문에 ApplicationReadyEvent에서 되돌립니다. 푸시 응답이 토큰 무효면 해당 기기를 비활성화하고 데드레터가 임계값을 넘으면 이벤트를 남깁니다.

결과

한 건이 실패할 때, 프로세스가 중단될 때, 토큰이 무효일 때의 세 경로가 각각 다른 처리로 갈라집니다. 같은 이벤트가 두 번 들어와도 되도록 소비 쪽을 멱등으로 뒀습니다. 이 동작은 단위 테스트에서 확인했습니다.

관리자 내보내기에서 엑셀이 수식으로 실행하던 CSV 셀

관리자 CSV 내보내기는 사용자가 쓴 값을 그대로 셀에 싣습니다. 셀이 등호로 시작하면 엑셀이 그걸 수식으로 실행합니다.

관리자 CSV 내보내기는 사용자가 쓴 값을 그대로 셀에 싣습니다. 셀이 등호로 시작하면 엑셀이 그걸 수식으로 실행합니다.

접근

셀 값의 첫 글자가 =, +, -, @ 중 하나면 앞에 작은따옴표를 붙입니다. 앞에 공백이나 BOM을 넣어 첫 글자를 숨기는 경우가 있어 그런 문자를 건너뛴 뒤에 판정합니다. 탭·개행이 먼저 나오면 그 자체를 위험으로 봅니다. 따옴표·쉼표·개행 처리는 기존 CSV 이스케이프를 그대로 씁니다. 내보내기는 임시 파일에 스트리밍으로 씁니다.

결과

내보낸 파일을 엑셀에서 열어도 셀 값이 수식으로 실행되지 않습니다. 검사는 첫 글자 기준이라 셀 중간에 들어간 함수 호출 문자열까지 보지는 않습니다.

업로드는 보고를 믿지 않고 객체를 확인, 변환 버퍼에는 상한

이미지는 R2 서명 URL로 클라이언트가 직접 올리고 서버는 완료 보고만 받습니다. 보고만 믿으면 올리지 않고 올렸다고 하는 요청을 거를 수 없습니다. 변환 출력 버퍼에 상한이 없으면 큰 파일 하나로 메모리를 채울 수도 있습니다.

이미지는 R2 서명 URL로 클라이언트가 직접 올리고 서버는 완료 보고만 받습니다. 보고만 믿으면 올리지 않고 올렸다고 하는 요청을 거를 수 없습니다. 변환 출력 버퍼에 상한이 없으면 큰 파일 하나로 메모리를 채울 수도 있습니다.

접근

완료 보고를 받으면 HeadObject로 객체가 실제로 있는지 확인한 뒤에 저장합니다. WebP 변환 출력은 상한을 둔 스트림에 쓰고 상한을 넘으면 예외를 던집니다.

결과

보고만으로 레코드가 만들어지는 경로와 변환 중 버퍼가 무한정 늘어나는 경로를 각각 막았습니다. 상한 값 자체는 운영 부하를 보지 않고 보수적으로 잡아 둔 값입니다.

SMTP가 죽으면 멀쩡한 API 컨테이너를 재시작시킬 수 있던 헬스체크

컨테이너 liveness·readiness가 /actuator/health 전체를 보고 있었습니다. 이 서비스는 spring-boot-starter-mail을 쓰는데 prod 프로필에서만 mail 지표가 켜져 있습니다. 그래서 SMTP 호스트가 안 되면 전체 상태가 DOWN으로 떨어집니다. liveness가 그 값을 보면 실패가 쌓여 컨테이너 재시작으로 이어집니다. readiness 실패에 드레인하는 플랫폼이면 인스턴스가 한꺼번에 빠집니다.

컨테이너 liveness·readiness가 /actuator/health 전체를 보고 있었습니다. 이 서비스는 spring-boot-starter-mail을 쓰는데 prod 프로필에서만 mail 지표가 켜져 있습니다. 그래서 SMTP 호스트가 안 되면 전체 상태가 DOWN으로 떨어집니다.

liveness가 그 값을 보면 실패가 쌓여 컨테이너 재시작으로 이어집니다. readiness 실패에 드레인하는 플랫폼이면 인스턴스가 한꺼번에 빠집니다.

접근

liveness와 readiness를 probe 그룹으로 옮겼습니다. probe 그룹은 Spring 자체 lifecycle만 반영하고 외부 의존성이 뒤집을 수 없습니다. 그룹 멤버를 추가하는 설정이 없고 HealthIndicator를 직접 구현한 코드도 없는 것을 확인했습니다. 전체 상태는 외부 알림용 체크로 남겨서 메일 장애가 어딘가에는 보이게 했습니다.

결과

이 설정은 운영에서 재시작이 일어나기 전에 매니페스트를 읽다가 발견해 고쳤습니다. 로컬 부팅에서 aggregate가 DOWN으로 떨어지는 것을 재현한 뒤 probe 경로로 분리했습니다.

docker-compose와 portainer 쪽은 이미 probe 그룹을 보고 있었고 이 매니페스트만 aggregate에 남아 있었습니다.

마이그레이션 105개를 베이스라인 5개로 접음

Flyway 마이그레이션이 105개까지 늘어 파일만 읽어서는 현재 스키마를 알기 어려웠습니다. 새 환경에서 스키마를 처음부터 다시 만드는 단계도 그만큼 길어졌습니다.105개 → 5개

Flyway 마이그레이션이 105개까지 늘어 파일만 읽어서는 현재 스키마를 알기 어려웠습니다. 새 환경에서 스키마를 처음부터 다시 만드는 단계도 그만큼 길어졌습니다.

접근

운영 스키마는 건드리지 않고 마이그레이션 히스토리만 다시 세웠습니다. 확장, 스키마, 루틴, 기준 데이터를 파일별로 나누고 결정은 ADR로 남겼습니다.

결과

새 환경은 V001~V005 다섯 파일로 스키마를 만들고 파일을 읽어 현재 스키마를 파악할 수 있습니다. 준비 시간이 얼마나 줄었는지는 전후를 재지 않아 적지 않습니다.

쌓인 위반을 기준선으로 들고 가며 신규만 막기

Detekt 위반이 쌓여 빌드가 계속 막혔고 모듈 간 의존성도 이미 여러 곳에서 경계를 넘고 있었습니다. 전부 고친 뒤에 진행하면 다른 작업이 멈춥니다.

Detekt 위반이 쌓여 빌드가 계속 막혔고 모듈 간 의존성도 이미 여러 곳에서 경계를 넘고 있었습니다. 전부 고친 뒤에 진행하면 다른 작업이 멈춥니다.

접근

기존 위반을 한 번에 없애지 않고 목록으로 고정했습니다. Detekt는 baseline.xml, 모듈 경계는 리포지토리 import 304줄·진입점 의존성 266줄·platform 도메인 import 21줄을 기준선 파일에 박아 뒀습니다. 목록에 없는 의존성이나 위반이 들어오면 아키텍처 테스트와 Detekt가 CI에서 실패합니다.

결과

기존 부채는 그대로 두고 더 늘어나지 않게 제한했습니다. 목록 파일의 줄 수가 곧 남은 부채의 크기입니다. 줄어들면 파일에서 빼는 방식으로 정리하고 있습니다.

설계 결정

Spring Modulith를 고른 이유

도메인별로 코드를 나누고 싶었지만 혼자 개발하면서 서비스별 배포와 분산 트랜잭션까지 관리하기에는 부담이 컸습니다. 그래서 하나의 서버로 배포하면서 모듈 경계를 검사할 수 있는 Spring Modulith를 골랐습니다.

경계 검사는 처음부터 깨끗한 상태가 아니었습니다. 기존 위반을 기준선 파일에 남기고 새 위반만 막는 방식은 별도 사례로 적었습니다.

모듈을 6개로 다시 나눈 기준

기능을 identity·place·oshikatsu·community·gateway·platform 6개 모듈로 나눴습니다. 모듈 정의는 모듈마다 둔 Kotlin ModuleMetadata에 두고 아키텍처 테스트가 구조 변경을 잡아 줍니다. 이전에는 도메인이 평면으로 흩어져 있어서 어느 쪽이 어느 쪽을 쓰는지 읽기 어려웠습니다.

캐시를 둘로 나눈 기준

장소·이벤트 목록처럼 잠깐 오래된 값이 보여도 되는 데이터는 Caffeine에 저장했습니다. 여러 서버가 같은 값을 봐야 하는 세션, 토큰 차단 목록, 요청 횟수는 Redis만 썼습니다. 데이터를 공유해야 하는 범위를 기준으로 둘을 갈랐습니다.

복원력 설정이 실제로 걸린 범위

Resilience4j는 Redis 조회와 DB 조회 경로에만 붙여 뒀습니다. ResilientRedisService는 서킷 브레이커와 재시도로 감쌌습니다. 실패하면 null을 돌려주고 호출 쪽이 캐시 미스처럼 처리합니다. 실패율 50%, 슬라이딩 윈도 10회, open 상태 30초가 현재 값입니다.

external-api 서킷 브레이커와 TimeLimiter 빈은 레지스트리에 등록만 돼 있고 실제 호출 경로에는 아직 붙지 않았습니다. 그래서 외부 호출의 대기 시간이 제한된다고는 할 수 없습니다. 지금은 실패가 누적된 Redis·DB 호출만 일시적으로 차단합니다.

위치 인증의 범위와 한계

PostGIS를 쓴 이유

장소는 좌표를 들고 있고 질의는 대부분 "내 주변 N km 안" 형태입니다. geography(Point, 4326) 타입에 저장하면 거리와 반경 판정을 DB의 공간 함수가 처리하고 GiST 인덱스가 후보를 먼저 줄여 줍니다. 좌표 저장과 거리 계산도 같은 타입 기준에서 돌아갑니다.

JSONB에 좌표를 담는 안도 봤지만 공간 인덱스와 공간 함수를 그대로 쓰지 못해 접었습니다. 숫자 컬럼으로도 방법은 있지만 이 서비스의 질의 형태에서는 공간 타입 쪽이 더 단순했습니다.

믿지 않는 값과 확인하는 값

좌표·시각·정확도·모의 위치 여부는 모두 클라이언트가 채워 보내는 값입니다. 서버는 그 값이 등록된 기기 키로 서명됐는지, 이전 인증과 비교해 물리적으로 말이 되는지까지만 확인할 수 있습니다.

모의 위치 여부는 단말이 스스로 신고하는 값이라 그 자체로는 근거가 약합니다. 대신 서명 대상 페이로드 안에 넣어 값을 지우거나 바꾸면 다시 서명해야 하도록 했습니다. iOS SDK가 제공자 이름에 빈 문자열을 보내는 경우가 있어서 그건 없는 값으로 취급합니다.

기기를 장악하면 서명까지 다시 할 수 있습니다. 그래서 자기 신고만으로 판정하지 않고 탐지 결과를 텔레메트리 이벤트로 남겨 나중에 다시 볼 수 있게 했습니다.

라이브 참석 인증

라이브 참석 인증은 장소 방문과 같은 가드를 쓰되 조건을 하나 더 두고 있습니다. 행사 당일에만 인증되고 기준 시각은 Asia/Seoul입니다. 행사에 장소가 연결돼 있어야 하고 정확도 상한과 반경을 넘으면 거부합니다. 성공하면 좌표·정확도·거리를 남깁니다.

도메인 구현

포인트 이중 지급 방지

포인트를 주는 지점이 방문·라이브 출석·게시글·댓글·좋아요로 늘면서 같은 행동에 두 번 적립될 수 있었습니다. 적립 원장에 먼저 insert를 시도하고 행이 실제로 만들어졌을 때만 잔액을 올립니다. 잔액을 먼저 올리고 원장을 쓰는 순서였다면 중복 요청이 그대로 통과합니다.

중복은 부분 유니크 인덱스가 막습니다. 방문은 사용자·장소·날짜(Asia/Seoul) 단위, 출석은 사용자·날짜 단위, 게시글·댓글·좋아요·라이브 출석은 사용자·행동·대상 엔티티 단위로 걸려 있습니다.

insert 쿼리는 conflict target 없이 ON CONFLICT DO NOTHING을 씁니다. 예외를 던지면 같은 트랜잭션에 묶인 게시물 생성까지 함께 롤백되기 때문에, 중복은 0행을 반환하고 조용히 끝나도록 했습니다.

피드 정렬

피드는 최신성, 좋아요·댓글 수, 작성자 활동량을 가중합해 점수를 내고 팔로우·관심 프로젝트 부스트를 곱합니다. 신고가 쌓인 글에는 페널티를 줍니다. 점수 순으로만 내보내면 한 사람 글이 연달아 나오기 때문에 같은 작성자가 연속되지 않게 다시 배열합니다.

모듈 간 이벤트

직접 호출 대신 이벤트

방문 인증 한 건이 끝나면 포인트 적립, 도감 갱신, 분석 기록, 랭킹 캐시 무효화가 따라옵니다. 인증 서비스가 직접 부르면 place 모듈이 identity와 gateway를 알아야 합니다. 그래서 인증은 PlaceVisitedEvent만 발행하고 각 모듈이 자기 리스너에서 받습니다. 모듈 간 직접 참조는 기준선 파일과 아키텍처 테스트가 감시하고 있습니다.

비동기 처리의 실패 범위

리스너는 @Async로 공용 taskExecutor에서 돌아 발행 트랜잭션을 막지 않습니다. 인증 결과를 먼저 돌려주는 대신 포인트 적립이 실패해도 인증 자체는 성공으로 남고 부수 처리의 실패는 로그로만 격리돼 즉시 드러나지 않습니다. 이건 제품 쪽 판단이었습니다.

같은 이벤트가 두 번 처리될 수 있어 소비 쪽을 멱등으로 만들었습니다. 포인트는 원장의 부분 유니크 인덱스가 막고 재시도가 필요한 푸시·피드 후보는 상태와 시도 횟수를 들고 가는 아웃박스에 쌓습니다.

권한과 데이터 경계

역할 이름 대신 권한 레벨

권한 검사가 전역 역할과 프로젝트 역할, 엔드포인트마다 다른 식으로 섞여 있어서 역할 이름과 실제 동작이 어긋났고 민감 데이터 접근 경계를 감사하기 어려웠습니다. 계정 타입은 관리자와 사용자 둘로 단순하게 두고 권한은 L0부터 L4까지 레벨로 나눴습니다. 검사는 역할 이름이 아니라 권한 코드와 범위(전역·프로젝트)로 합니다.

민감 경계는 마스킹 폴백 없이 끊었습니다. 개인정보·원본 위치·디바이스 토큰·감사 로그 내보내기는 L4만 가질 수 있고 L3는 하드 403입니다.

한 번에 전부 옮기는 안은 회귀 위험이 커서 기각했습니다. 평가기를 그림자 모드로 먼저 넣고 민감 엔드포인트부터 게이트를 걸고 낮은 레벨 그룹부터 차례로 옮긴 뒤 패리티를 확인하고 옛 검사식을 지웠습니다. 운영 중 불일치가 나오면 설정 플래그로 그림자나 레거시로 되돌릴 수 있게 남겼고 부여가 바뀌면 기존 토큰이 무효가 되도록 권한 버전을 뒀습니다.

보유기간을 코드가 아니라 설정에서

확정된 운영정책의 보관 기간은 탈퇴 후 계정정보 30일, 비활성 디바이스 30일, 동의·권리행사 이력 3년, 보안·접속·오류 로그 6개월, 제재 소명 14일입니다. 기존 서버는 이 기간이 코드 곳곳에 흩어져 있었습니다.

보유기간과 기한을 설정 한 곳에서 읽게 하고 탈퇴 시점을 기록해 그 시점 기준으로 집행하게 했습니다. 스케줄 잡이 비활성 계정 익명화, 비활성 디바이스 삭제, 기간이 지난 이력과 감사 로그 삭제를 돌립니다. 탈퇴 계정을 즉시 지우면 참조 안정성을 해치고 회귀 위험이 커서 익명화를 택했습니다. 정책값은 운영이 바뀔 때마다 배포할 필요가 없도록 코드에 박지 않았습니다.

포렌식 로그는 엔드포인트별로 본문 기록 허용·차단 목록을 두고 인증·키·업로드 경로를 기본 차단에 넣었습니다. 차단 경로는 본문 대신 마커를 남겨 왜 안 보이는지가 로그에 드러나게 했습니다.

검색엔진 없이 전문 검색

커뮤니티 글 조회가 최신순과 트렌딩뿐이라 키워드 검색이 없었습니다. 외부 검색엔진을 붙이는 대신 게시글 테이블에 tsvector 컬럼과 GIN 인덱스를 두고 트리거로 동기화해 DB 안에서 해결했습니다. 지금 규모에서 구현과 운영 비용이 낮다고 봤습니다. 정렬은 관련도를 먼저 보고 작성 시각을 보조로 씁니다.

검색 경로도 기존 차단 정책을 그대로 통과시켜서 차단한 작성자의 글은 결과에서 빠집니다. 한계는 문서에 적어 뒀습니다. simple 사전을 쓰고 있어 형태소 분석 품질이 제한적이고 다국어 품질은 후속 과제입니다. 롤백도 비대칭으로 잡았습니다. 라우트와 서비스 호출만 지우고 컬럼·트리거·인덱스는 데이터를 보존한 채 남깁니다.

oshi@log | Portfolio