Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions docs/backend/worklog/2026-08-07-S15P11A705-397-me-activity-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 나의 활동 기록 집계 API를 더했다 — 날짜 경계를 KST로 고정한다

- **날짜**: 2026-08-07
- **추적**: S15P11A705-397
- **관련**: `GET /v1/me/activity` · [S15P11A705-388](https://ssafy.atlassian.net/browse/S15P11A705-388)(한글 정렬 함정의 출처)

발표 시연에서 화면을 채울 "나의 활동 기록" 페이지를 붙이려는데, 내 기록을 집계해 내려주는 경로가 없었다. 지금까지는 단건·목록 조회뿐이라 프론트가 기록을 전부 받아 직접 세야 했다. `GET /v1/me/activity` 하나로 다섯 덩어리(`totals`·`months`·`areas`·`counts`·`highlights`)를 내려준다.

## 기간을 전체 누적으로 고정한 이유

설계 단계에서 목업 제목("2026년, 당신의 기록")과 본문("첫 기록 2026-01-14부터 8개월")이 어긋나 있었다. 올해만·최근 12개월 롤링·전체 누적 셋 중 **전체 누적**으로 정했다. 기간 파라미터를 두지 않는다 — 화면이 "지금까지"를 보여주는 자리라 범위를 고를 여지가 없고, 파라미터를 열면 캐시·검증·문서가 함께 늘어난다.

## 날짜 경계는 전부 KST다

이 티켓에서 가장 조용히 틀릴 수 있는 자리다. `record.created_at`은 `TIMESTAMPTZ`라 `date_trunc`를 그냥 쓰면 서버 타임존을 탄다. KST 2026-05-18 00:30에 남긴 기록은 UTC로 2026-05-17 15:30이라, UTC로 끊으면 "가장 붐빈 하루"가 하루씩 밀린다. 한국 서비스에서 사용자의 하루는 KST의 하루이므로 월·일 집계를 전부 `created_at AT TIME ZONE 'Asia/Seoul'` 위에서 계산한다.

경계에 걸친 시각을 일부러 넣은 테스트를 뒀다(`dayBoundaryFollowsSeoulTime`) — UTC로 끊는 구현이면 17일이 3건이 되어 실패한다.

## 빈 달 채우기를 DB가 아니라 애플리케이션에서 한다

`months`는 첫 기록이 있는 달부터 **이번 달까지** 빠짐없이 이어져야 한다. 막대그래프가 x축을 건너뛰면 "그 달에 안 다녔다"가 아니라 "그 달이 없다"로 읽히기 때문이다(3월과 5월만 있으면 두 막대가 붙어 보인다).

문제는 구간의 끝인 "이번 달"이다. `generate_series`로 SQL에서 채우면 그 값을 DB 서버의 시계와 타임존이 정하게 된다. 그래서 SQL은 **기록이 있는 달만** 주고, 채우는 일은 서비스가 `YearMonth.now(KST)`를 기준으로 한다 — 기준이 한 곳에 남는다.

`counts.recordedMonthCount`(기록이 실제로 있는 달의 수)와 `months`의 길이는 다르다. 후자는 빈 달까지 채운 구간 길이다. 화면이 두 숫자를 다 쓴다("8개월" 옆에 "기록한 달 7개월").

## 자치구는 주소 문자열을 자른다

`core.place`에 행정구역 컬럼이 없어 `split_part(address, ' ', 2)`로 두 번째 조각을 시·구로 본다. "서울 마포구 성미산로 198" 같은 형식을 전제한 값이라 도로명·지번이 섞이거나 형식을 벗어난 주소에서는 정확하지 않다. 조각이 없으면 `nullif(..., '')`로 `NULL`이 되어 `areas`와 `districtCount` 양쪽에서 자연히 빠진다.

좌표 클러스터링이면 "성수동"처럼 구보다 작은 단위까지 잡히지만 카드에 붙일 **이름**이 나오지 않는다. 화면이 "마포구"라는 라벨을 요구하므로 주소 파싱을 골랐다.

## 동점 정렬에 `COLLATE "C"`를 명시한다

`areas`의 건수 동점은 지역명으로 끊는다. 그런데 한글 이름을 DB 기본 collation으로 정렬하면 **운영 DB와 테스트 컨테이너의 locale이 달라 순서가 갈린다** — S15P11A705-388이 같은 함정을 만나 그때는 이름 정렬 자체를 피했다(`keywordId`로 끊었다). 여기서는 파생 문자열이라 이름 말고 끊을 키가 없으므로, 피하는 대신 `ORDER BY count(*) DESC, district COLLATE "C" ASC`로 collation을 명시해 고정했다.

## 집계를 한 쿼리로 묶지 않았다

서로 다른 테이블의 독립 집계라 조인이 카운트를 곱한다(Record 3건 × Collection 2건 = 6). `MemberSummaryService`가 같은 이유로 이미 카운트 넷을 따로 내고 있고, 진입당 1회 호출되는 경로에서 인덱스를 타는 집계 여섯은 그 복잡도를 살 이유가 되지 않는다.

대신 대상 집합의 정의는 한 곳에 뒀다 — `MY_RECORDS` CTE(내 활성 Record + 장소 + KST 벽시계 + 시·구)를 네 쿼리가 공유한다.

**엔티티를 거치지 않으므로 `@SQLRestriction`이 걸리지 않는다.** JPA 리포지토리에서는 삭제 조건을 적지 않는 것이 규약인데(S15P11A705-200), 이 SQL에서는 정반대로 `deleted_at IS NULL`을 직접 적어야 한다. 맥락 메모·컬렉션 카운트는 JPA 파생 쿼리를 쓰므로 그쪽은 조건을 적지 않는다.

## 범위 밖

- **AI 키워드**("가장 자주 쓴 표현"): `ai` 스키마 소관이라 쓰지 않는다.
- **장소 분류**(카페·식당): `core.place`에 category 컬럼이 없다.
- **지도**: 화면에서 빼기로 해 좌표를 응답에 싣지 않는다. 설계 과정에서 동네별 정적 SVG 지도를 검토했다가, 프론트 공수가 가장 큰 조각이라 기능 축소 결정으로 걷어냈다.

## 검증

`./gradlew clean check --no-daemon` 통과. 전체 683 테스트 0 실패다. 새 테스트 13개는 `MeActivityApiTests`에 있고, 마이그레이션·신규 인덱스는 없다.
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.pinlog.pinlogback.domain.member.dto.MeActivityResponse;
import com.pinlog.pinlogback.domain.member.dto.MeSummaryResponse;
import com.pinlog.pinlogback.domain.member.dto.WithdrawalStartResponse;
import com.pinlog.pinlogback.domain.member.service.MemberActivityService;
import com.pinlog.pinlogback.domain.member.service.MemberSummaryService;
import com.pinlog.pinlogback.domain.member.service.WithdrawalAuthorizationService;
import com.pinlog.pinlogback.global.security.authentication.LoginMember;
Expand All @@ -24,13 +26,16 @@ public class MeController {

private final WithdrawalAuthorizationService withdrawalAuthorizationService;
private final MemberSummaryService memberSummaryService;
private final MemberActivityService memberActivityService;

public MeController(
WithdrawalAuthorizationService withdrawalAuthorizationService,
MemberSummaryService memberSummaryService
MemberSummaryService memberSummaryService,
MemberActivityService memberActivityService
) {
this.withdrawalAuthorizationService = withdrawalAuthorizationService;
this.memberSummaryService = memberSummaryService;
this.memberActivityService = memberActivityService;
}

/** 마이페이지 요약(API 명세 3.5). 진입 시 1회 호출한다. */
Expand All @@ -39,6 +44,19 @@ public MeSummaryResponse summary(@LoginMember MemberPrincipal me) {
return memberSummaryService.summarize(me.memberId());
}

/**
* 나의 활동 기록 집계(S15P11A705-397). 진입 시 1회 호출한다.
*
* <p>{@code /summary}를 대체하지 않고 옆에 둔다 — 그쪽은 계정 정보와 카운트 넷이고, 이쪽은
* 월별·지역별 집계라 호출 시점과 응답 크기가 다르다.
*
* <p>기간 파라미터가 없다. 전체 누적 고정이다.
*/
@GetMapping("/activity")
public MeActivityResponse activity(@LoginMember MemberPrincipal me) {
return memberActivityService.summarize(me.memberId());
}

/**
* 회원 탈퇴 <b>시작</b>. 여기서는 아무것도 지우지 않고 공급자 인가 URL만 돌려준다(BD-48).
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.pinlog.pinlogback.domain.member.dto;

import java.time.LocalDate;
import java.util.List;

/**
* 나의 활동 기록 집계 응답(S15P11A705-397). 화면 진입 시 1회 호출된다.
*
* <p><b>{@code memberId}를 담지 않는다.</b> 개인 API는 서버가 쿠키로 사용자를 식별하므로
* 클라이언트가 자신의 내부 ID를 알 필요가 없다(08 §1.1, BD-14 식별자 은닉).
*
* <p>기간은 <b>전체 누적</b>이다. 기간 파라미터를 두지 않는다 — 화면이 "지금까지"를 보여주는
* 자리라 범위를 고를 여지가 없고, 파라미터를 열면 캐시·검증·문서가 함께 늘어난다.
*
* <p><b>날짜 경계는 전부 KST다.</b> {@code record.created_at}은 {@code TIMESTAMPTZ}라 UTC로
* 끊으면 KST 자정 직후의 기록이 전날로 붙는다. 월·일 집계는 모두
* {@code created_at AT TIME ZONE 'Asia/Seoul'} 위에서 계산한다.
*
* @param totals 화면 상단의 큰 숫자
* @param months 첫 기록이 있는 달부터 이번 달까지. 기록이 없는 달도 0으로 채워 빈칸 없이 이어진다
* @param areas 주소의 시·구 기준 상위 5곳
* @param counts 기록에 딸린 것들의 개수
* @param highlights 기록에서 그대로 뽑은 단일 사실들
*/
public record MeActivityResponse(
Totals totals,
List<MonthCount> months,
List<AreaCount> areas,
Counts counts,
Highlights highlights
) {

/**
* @param placeCount 기록한 장소 수. 활성 Record 수와 같다 — {@code uq_record_active}가
* 회원·장소당 활성 Record를 1개로 묶어 두기 때문이다(V3:43)
* @param districtCount 발자국이 닿은 자치구 수. 주소에서 시·구를 못 뽑는 행은 세지 않는다
* @param firstRecordedOn 첫 기록일(KST). 기록이 없으면 {@code null}
*/
public record Totals(long placeCount, long districtCount, LocalDate firstRecordedOn) {
}

/** @param month {@code YYYY-MM}(KST) */
public record MonthCount(String month, long recordCount) {
}

/** @param district 주소에서 뽑은 시·구 이름 */
public record AreaCount(String district, long recordCount) {
}

/**
* @param contextCount 활성 맥락 메모 수
* @param collectionCount 활성 컬렉션 수
* @param recordedMonthCount 기록이 <b>실제로 있는</b> 달의 수. {@code months}의 길이와 다르다 —
* 그쪽은 빈 달까지 채운 구간 길이다
*/
public record Counts(long contextCount, long collectionCount, long recordedMonthCount) {
}

/**
* @param firstPlaceName 처음 기록한 장소 이름. 기록이 없으면 {@code null}
* @param lastPlaceName 가장 최근에 기록한 장소 이름. 기록이 없으면 {@code null}
* @param busiestDay 하루에 가장 많이 기록한 날. 기록이 없으면 {@code null}
*/
public record Highlights(String firstPlaceName, String lastPlaceName, BusiestDay busiestDay) {
}

/** @param date 그 날짜(KST) */
public record BusiestDay(LocalDate date, long recordCount) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package com.pinlog.pinlogback.domain.member.repository;

import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;

import com.pinlog.pinlogback.domain.member.dto.MeActivityResponse.AreaCount;
import com.pinlog.pinlogback.domain.member.dto.MeActivityResponse.BusiestDay;
import com.pinlog.pinlogback.domain.member.dto.MeActivityResponse.MonthCount;

/**
* 나의 활동 기록 집계 쿼리(S15P11A705-397). 전부 {@code core} 스키마만 읽는다.
*
* <p>JPQL이 아니라 SQL로 두는 이유는 세 가지다 — {@code date_trunc}·{@code split_part} 같은
* PostgreSQL 함수가 필요하고, 결과가 엔티티가 아니라 집계 투영이며, 아래 {@code MY_RECORDS} CTE를
* 네 쿼리가 공유해 <b>대상 집합의 정의를 한 곳에 두기</b> 위해서다.
*
* <p><b>{@code AT TIME ZONE 'Asia/Seoul'}이 이 클래스의 핵심이다.</b> {@code created_at}은
* {@code TIMESTAMPTZ}라 그냥 {@code date_trunc}하면 서버 타임존을 탄다. KST 자정 직후에 남긴
* 기록(= UTC로는 전날 오후)이 전날에 붙어 "가장 붐빈 하루"가 하루씩 밀린다.
*
* <p><b>삭제 조건을 SQL에 직접 적는다.</b> 엔티티를 거치지 않으므로 {@code @SQLRestriction}이
* 걸리지 않는다 — JPA 리포지토리에서 조건을 생략해도 되는 것과 정반대다.
*/
@Repository
public class MemberActivityRepository {

/**
* 집계 대상 집합. 내 활성 Record에 장소를 붙이고 KST 벽시계 시각을 미리 만들어 둔다.
*
* <p>{@code district}를 여기서 한 번만 뽑는다. 주소는 "서울 마포구 성미산로 198"처럼 공백으로
* 끊긴 문자열이라 두 번째 조각이 시·구다. {@code nullif(..., '')}로 조각이 없는 주소를
* {@code NULL}로 만들어 집계에서 자연히 빠지게 한다 — 도로명·지번이 섞이거나 형식을 벗어난
* 주소는 이 방식으로 정확히 뽑히지 않으며, 그 한계를 안고 쓰는 값이다(티켓 상세 내용).
*/
private static final String MY_RECORDS = """
WITH my AS (
SELECT r.id AS record_id,
r.created_at AS created_at,
p.name AS place_name,
(r.created_at AT TIME ZONE 'Asia/Seoul') AS local_ts,
nullif(split_part(p.address, ' ', 2), '') AS district
FROM core.record r
JOIN core.place p ON p.id = r.place_id
WHERE r.member_id = :memberId
AND r.deleted_at IS NULL
)
""";

private static final String TOTALS_SQL = MY_RECORDS + """
SELECT count(*) AS place_count,
count(DISTINCT district) AS district_count,
min(local_ts)::date AS first_recorded_on,
count(DISTINCT date_trunc('month', local_ts)) AS recorded_month_count
FROM my
""";

/** 기록이 있는 달만 준다. 빈 달 채우기는 서비스가 한다 — SQL로 하면 이번 달을 DB 시계가 정한다. */
private static final String MONTHS_SQL = MY_RECORDS + """
SELECT to_char(date_trunc('month', local_ts), 'YYYY-MM') AS month,
count(*) AS record_count
FROM my
GROUP BY 1
ORDER BY 1
""";

/**
* 동점을 {@code COLLATE "C"}로 끊는다. 한글 이름을 DB 기본 collation으로 정렬하면 운영 DB와
* 테스트 컨테이너의 locale이 달라 순서가 갈린다 — S15P11A705-388이 같은 함정을 만나
* 이름 정렬 자체를 피했다. 여기서는 이름 말고 끊을 키가 없으므로 collation을 명시해 고정한다.
*/
private static final String AREAS_SQL = MY_RECORDS + """
SELECT district, count(*) AS record_count
FROM my
WHERE district IS NOT NULL
GROUP BY district
ORDER BY count(*) DESC, district COLLATE "C" ASC
LIMIT 5
""";

/** 동점이면 더 최근 날짜를 고른다 — 지난 기록보다 최근 기록이 화면에서 더 말이 된다. */
private static final String BUSIEST_DAY_SQL = MY_RECORDS + """
SELECT local_ts::date AS day, count(*) AS record_count
FROM my
GROUP BY 1
ORDER BY count(*) DESC, 1 DESC
LIMIT 1
""";

/**
* 처음·마지막 기록의 장소 이름을 한 번에 가져온다.
*
* <p>{@code id}가 동률을 끊는다. {@code created_at}은 DB {@code now()}가 채우므로 한
* 트랜잭션에서 만들어진 Record들이 같은 값을 가질 수 있고, 그때 이름이 회차마다 갈린다.
*/
private static final String FIRST_LAST_PLACE_SQL = MY_RECORDS + """
SELECT
(SELECT place_name FROM my ORDER BY created_at ASC, record_id ASC LIMIT 1) AS first_place_name,
(SELECT place_name FROM my ORDER BY created_at DESC, record_id DESC LIMIT 1) AS last_place_name
""";

private final NamedParameterJdbcTemplate jdbcTemplate;

public MemberActivityRepository(NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}

public Totals findTotals(Long memberId) {
return jdbcTemplate.queryForObject(TOTALS_SQL, params(memberId), (rs, rowNum) -> {
java.sql.Date firstOn = rs.getDate("first_recorded_on");
return new Totals(
rs.getLong("place_count"),
rs.getLong("district_count"),
firstOn == null ? null : firstOn.toLocalDate(),
rs.getLong("recorded_month_count"));
});
}

/** 기록이 있는 달만, 오래된 순. */
public List<MonthCount> findRecordedMonths(Long memberId) {
return jdbcTemplate.query(MONTHS_SQL, params(memberId), (rs, rowNum) ->
new MonthCount(rs.getString("month"), rs.getLong("record_count")));
}

public List<AreaCount> findTopAreas(Long memberId) {
return jdbcTemplate.query(AREAS_SQL, params(memberId), (rs, rowNum) ->
new AreaCount(rs.getString("district"), rs.getLong("record_count")));
}

public Optional<BusiestDay> findBusiestDay(Long memberId) {
return jdbcTemplate.query(BUSIEST_DAY_SQL, params(memberId), (rs, rowNum) ->
new BusiestDay(rs.getDate("day").toLocalDate(), rs.getLong("record_count")))
.stream()
.findFirst();
}

public FirstLastPlace findFirstAndLastPlace(Long memberId) {
return jdbcTemplate.queryForObject(FIRST_LAST_PLACE_SQL, params(memberId), (rs, rowNum) ->
new FirstLastPlace(rs.getString("first_place_name"), rs.getString("last_place_name")));
}

private MapSqlParameterSource params(Long memberId) {
return new MapSqlParameterSource(Map.of("memberId", memberId));
}

/**
* {@code TOTALS_SQL} 한 번의 결과. 응답 DTO가 아니라 쿼리 결과라서 별도 타입으로 둔다 —
* {@code recordedMonthCount}는 응답에서 {@code counts} 쪽으로 갈라져 담긴다.
*/
public record Totals(long placeCount, long districtCount, LocalDate firstRecordedOn,
long recordedMonthCount) {
}

/** 둘 다 기록이 없으면 {@code null}이다. */
public record FirstLastPlace(String firstPlaceName, String lastPlaceName) {
}
}
Loading
Loading