왜 만들었나AI 코딩 에이전트에게 TDD로 구현해 달라고 하면 대개 "테스트부터 작성하겠습니다"라고 답한다. 그런데 막상 작업을 들여다보면 테스트와 구현이 한 번에 나오거나, 테스트를 한 번도 실패시켜 보지 않은 채 초록 바만 보여 주는 경우가 많다. 프롬프트에 규칙을 적어 두는 것만으로는 사이클이 지켜지지 않는다. 그래서 규칙을 프롬프트가 아니라 훅에 넣었다. 이 하네스는 Java/Kotlin 백엔드 프로젝트에 설치하는 .claude/ 템플릿이다. RED→GREEN→REFACTOR 순서를 어기는 편집을 Claude Code의 PreToolUse 훅이 실제로 막는다. 핵심 원칙은 한 줄이다. 상태는 에이전트의 선언이 아니라 Gradle이 쓴 파일로만 바뀐다. 에이전트가 "RED 확인했습니다"라고 말해도 ..
이전 글(https://ohksj77.tistory.com/292)에서는 지도 홈 API(GET /map/me)를 그리드 셀 캐시, 픽셀 병합, 방향 기반 prefetch로 빠르게 만든 과정을 다뤘다. 캐시를 붙이면 반드시 따라오는 질문이 있다. 사진이 바뀌었을 때 무엇을 얼마나 버려야 하는가. 너무 많이 버리면 캐시를 붙인 의미가 없고, 너무 적게 버리면 방금 올린 사진이 지도에 보이지 않는다. 이 글에서는 Lokit이 이 문제를 다음 네 가지 장치로 나눠 푼 과정을 정리한다.커플 단위 변경 시퀀스(MapMutationTracker)클라이언트와 주고받는 dataVersion / lastDataVersion포인트 기반 무효화와 커플 단위 무효화의 조합기본 앨범(albumId null) 정규화 요구사항사진..
Lokit은 커플이 함께 찍은 사진을 지도 위에 기록하는 아카이빙 서비스다. 앱을 열면 가장 먼저 보이는 화면이 지도이고, 사용자는 이 지도를 계속 끌고(pan) 확대·축소(zoom)한다. 이때, zoom 레벨 별로 사진(POI)들이 클러스터링이 되었다가 분리된다. 이 글에서는 지도 홈 API(GET /map/me)가 지도를 움직일 때마다 DB를 처음부터 다시 훑지 않게 만든 과정을 정리한다. PostGIS 공간 쿼리, 그리드 셀 단위 캐시, 픽셀 거리 기반 클러스터 병합, 이동 방향과 속도를 보고 미리 적재하는 prefetch, 그리고 연속 zoom을 0.5 단위로 양자화한 이유를 다룬다. 캐시 무효화와 dataVersion 이야기는 분량이 많아 다음 글(https://ohksj77.tistory.co..
요구사항Lokit은 커플이 함께 찍은 사진을 촬영 위치 기준으로 지도에 쌓아 두는 서비스다. 사용자가 앱에서 가장 많이 하는 일은 지도를 움직이는 것이고, 그 다음은 알림함과 앨범 목록을 보는 것이다. 이 흐름을 DB 쿼리로 옮겨 보면 서로 성격이 꽤 다른 쿼리들이 나온다.기능쿼리 형태호출 빈도지도 이동화면 영역(bbox) 안의 사진 조회, taken_at DESC 정렬지도를 움직일 때마다알림함수신자별 sent_at DESC, id DESC 정렬 + 오프셋 페이지네이션알림함 진입 시홈 배지안 읽은 알림이 하나라도 있는지(EXISTS)홈 진입마다커플 조회user_id로 소속 커플 찾기(EXISTS 서브쿼리)거의 모든 인가 경로고아 파일 정리 배치최근 1일 안에 생성된 사진 URL매일 새벽 3시앨범 목록co..
Lokit 서버는 커플, 앨범, 사진, 지도, 알림, 사용자라는 여섯 개의 도메인을 하나의 Spring Boot 애플리케이션 안에서 다룬다. 이 글은 이 도메인들을 DDD의 바운디드 컨텍스트처럼 나누고 각 컨텍스트를 헥사고날 계층으로 쌓은 구조를 설명한다. 그리고 그 규칙을 사람의 기억이 아니라 ArchUnit 테스트로 강제한 방법을 정리한다. 요구사항도메인 규칙은 프레임워크와 무관하게 테스트할 수 있어야 한다. "커플은 최대 2명", "연결 해제 후 31일이 지나면 재연결 불가" 같은 규칙은 서비스의 핵심이다. JPA나 Spring 컨텍스트 없이 순수 단위 테스트로 검증할 수 있어야 한다.외부 시스템 변경이 도메인으로 번지지 않아야 한다. 실제로 인프라는 AWS에서 GCP로 옮겨 갔고(d45b421 커..
Lokit은 커플이 함께 찍은 사진을 지도 위에 아카이빙하는 서비스다. 서버는 Spring Boot 4.0 + Kotlin 2.2 + Java 24 위에서 동작하고, 요청 처리 전체를 Virtual Thread에 올려 두었다. 이 글은 VT 위에서 StructuredTaskScope로 한 요청 안의 독립 I/O를 병렬화한 과정과, 병렬화가 만든 새로운 문제인 "DB 커넥션 수요 폭증"을 세마포어로 다루다가 결국 어떻게 정리했는지를 다룬다.요구사항병렬화 대상이 된 흐름은 크게 세 종류다.흐름한 요청 안의 독립 작업성격지도 홈 GET /map/me커플 앨범 목록 조회, 뷰포트 내 사진 클러스터 조회DB 2건 (서로 독립)사진 업로드 PhotoCommandService.createS3 객체 존재 확인, 좌표 ..
Lokit은 커플이 함께 찍은 사진을 지도 위에 아카이빙하는 서비스다. 사진 한 장은 두 곳에 나뉘어 저장된다. 이미지 바이너리는 S3에, 위치·앨범·설명 같은 메타데이터는 PostgreSQL에 저장된다. 두 저장소는 하나의 트랜잭션으로 묶을 수 없다. 이 글은 "어차피 둘 중 하나는 실패할 수 있다"는 전제를 받아들이고, 실패가 사용자에게 보이지 않는 방향으로만 일어나도록 설계한 과정을 정리한다. 요구사항업로드 대역폭은 서버를 거치지 않는다. 클라이언트가 Presigned URL로 S3에 직접 PUT한 뒤, 서버에는 객체 URL과 메타데이터만 등록한다.삭제 API의 성공 여부는 S3 상태와 무관해야 한다. S3 응답이 느리거나 일시적으로 실패한다고 해서 사용자의 "사진 삭제"가 실패하면 안 된다.DB가..
커플 서비스의 데이터에는 끝이 있다. 커플은 헤어지고, 사용자는 탈퇴하며, 알림은 금방 의미를 잃는다. 그런데 "언제 지울지"를 결정하는 시점에 요청이 들어오는 경우는 거의 없다. 연결을 끊은 지 31일이 지났다고 해서 누군가 API를 호출해 주지는 않는다. 이 글은 Lokit에서 시간이 흘러야 상태가 바뀌는 데이터를 스케줄러로 처리한 설계를 정리하고, 코드를 다시 점검하면서 발견한 공백까지 함께 기록한다. 요구사항커플 연결 끊기 후 31일 동안은 재연결할 수 있어야 한다. 실수나 다툼으로 연결을 끊었다가 다시 돌아오는 경우를 고려한 유예기간이다.유예기간이 지나면 커플의 사진, 앨범, 댓글, 이모티콘과 S3 원본을 파기한다. 개인정보 보호법 제21조는 보유기간 경과나 처리 목적 달성 시 개인정보를 지체 ..
