DEVLOG

두 요청이 모두 ‘최초 조회’라고 판단했다

조회 기록을 확인한 뒤 저장하는 Check-Then-Act 경쟁 조건을 조건부 UPDATE, 복합 UNIQUE, INSERT IGNORE로 원자적으로 판정한 과정을 기록했습니다.

TRIPFEED 게시글과 조회 기록 테이블 관계

앞선 글에서 조회수 증가를 DB 원자적 UPDATE로 바꾸며 Lost Update를 해결했습니다. 하지만 이것만으로는 “같은 사용자가 같은 게시글을 1분 안에 다시 보면 집계하지 않는다”는 정책을 구현할 수 없었습니다.

조회 기록을 확인한 뒤 증가 여부를 판단하는 과정 자체에도 동시성 문제가 남아 있었습니다.

조회한 뒤 저장하면 안전할까

처음 생각한 흐름은 단순했습니다.

조회 기록을 찾는다
→ 기록이 없거나 1분이 지났는지 확인한다
→ 유효한 조회라면 기록을 저장하고 조회수를 올린다

한 요청씩 실행될 때는 의도대로 동작합니다. 하지만 조회 기록이 없는 상태에서 같은 사용자의 요청 두 개가 거의 동시에 들어오면, 두 요청 모두 아직 기록이 없다고 판단할 수 있습니다.

요청 A: 조회 기록 SELECT → 없음
요청 B: 조회 기록 SELECT → 없음

요청 A: 최초 조회로 판단 → 기록 INSERT → 조회수 +1
요청 B: 최초 조회로 판단 → 기록 INSERT → 조회수 +1

조건을 확인한 시점과 실제로 행동하는 시점 사이에 다른 요청이 끼어드는 Check-Then-Act 경쟁 조건입니다. 조회수 증가 연산이 원자적이어도, 증가시킬지를 결정하는 과정이 분리되어 있으면 같은 조회가 두 번 반영될 수 있습니다.

회원과 비회원을 하나의 조회자로 표현하기

정책상 로그인한 회원과 검색으로 들어온 비회원의 조회를 모두 집계해야 했습니다. 회원은 userId, 비회원은 서버가 쿠키로 발급한 UUID 형태의 visitorId로 구분했습니다.

두 식별자의 형식이 다르기 때문에 조회 기록에는 방문자 유형과 ID를 나누어 저장했습니다.

CREATE TABLE post_view_history (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    post_id BIGINT NOT NULL,
    viewer_type ENUM('USER', 'VISITOR') NOT NULL,
    viewer_id VARCHAR(64) NOT NULL,
    last_viewed_at TIMESTAMP NOT NULL,

    CONSTRAINT uk_post_viewer
        UNIQUE (post_id, viewer_type, viewer_id),
    FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
);

복합 UNIQUE 제약으로 하나의 게시글과 조회자 조합은 항상 한 행만 갖습니다. 동일한 최초 조회를 여러 요청이 동시에 저장하려 해도 데이터베이스가 한 요청만 성공시킬 수 있는 기준이 생겼습니다. 게시글이 삭제될 때 의미가 없어진 조회 기록도 함께 제거되도록 외래 키에는 ON DELETE CASCADE를 적용했습니다.

posts와 post_view_history 테이블의 관계를 포함한 데이터베이스 다이어그램
회원과 비회원의 마지막 조회 시점을 post_view_history 한 행으로 관리합니다.

SELECT 대신 결과 행 수로 판정하기

애플리케이션에서 기록을 먼저 SELECT하고 판단하지 않도록, 조회 기록을 바꾸는 연산의 결과 행 수가 곧 유효 조회 여부가 되게 만들었습니다.

조건부 UPDATE
├─ 1행 성공 → 1분이 지난 재조회 → 조회수 +1
└─ 0행
   └─ INSERT IGNORE
      ├─ 1행 성공 → 최초 조회 → 조회수 +1
      └─ 0행 → 중복 조회 또는 동시 요청에서 뒤진 요청 → +0

1분이 지난 기록만 조건부 UPDATE

기존 기록이 있고 마지막 조회로부터 1분이 지났다면, 해당 행의 시간만 현재 시각으로 바꿉니다. 시간 조건을 WHERE 절에 포함했기 때문에 먼저 조회한 뒤 애플리케이션에서 비교할 필요가 없습니다.

@Modifying
@Query("""
    UPDATE PostViewHistory pvh
    SET pvh.lastViewedAt = :now
    WHERE pvh.post.id = :postId
      AND pvh.viewerType = :viewerType
      AND pvh.viewerId = :viewerId
      AND pvh.lastViewedAt <= :threshold
""")
int updateLastViewedAtIfExpired(
    Long postId,
    ViewerType viewerType,
    String viewerId,
    LocalDateTime now,
    LocalDateTime threshold
);

반환값이 1이면 1분이 지난 유효한 재조회입니다. 동시에 여러 요청이 들어와도 첫 요청이 시간을 갱신하면, 뒤의 요청은 더 이상 last_viewed_at <= threshold 조건을 만족하지 못해 0을 반환합니다.

기록이 없다면 INSERT IGNORE

조건부 UPDATE가 0을 반환하는 경우는 기록이 없거나, 1분 내 중복 조회이거나, 다른 요청이 먼저 갱신한 경우입니다. 이때 최초 조회인지 확인하기 위해 INSERT를 시도합니다.

JPQL과 JPA 기반 QueryDSL은 INSERT를 지원하지 않습니다. save() 후 UNIQUE 예외를 처리하는 대신, 성공 여부를 행 수로 바로 받을 수 있도록 MySQL의 INSERT IGNORE를 Native Query로 사용했습니다.

@Modifying
@Query(value = """
    INSERT IGNORE INTO post_view_history (
        post_id, viewer_type, viewer_id, last_viewed_at
    ) VALUES (
        :postId, :viewerType, :viewerId, :now
    )
""", nativeQuery = true)
int insertIfAbsent(
    Long postId,
    String viewerType,
    String viewerId,
    LocalDateTime now
);
  • 반환값 1: 기존 기록이 없어 INSERT에 성공한 최초 조회
  • 반환값 0: 복합 UNIQUE가 같은 조회자 기록을 발견해 무시한 요청

1분 내 재조회와 동시에 들어온 최초 조회를 애플리케이션의 사전 SELECT 없이 같은 흐름으로 처리할 수 있게 됐습니다.

두 원자적 연산을 하나의 판정으로 묶기

조회 기록 서비스는 먼저 조건부 UPDATE를 시도하고, 성공하지 못했을 때만 INSERT IGNORE를 실행합니다.

public boolean isCountableView(
    Long postId,
    ViewerType viewerType,
    String viewerId
) {
    LocalDateTime now = LocalDateTime.now();
    LocalDateTime threshold = now.minusMinutes(1);

    int updatedRows = postViewHistoryRepository
        .updateLastViewedAtIfExpired(
            postId, viewerType, viewerId, now, threshold
        );

    if (updatedRows == 1) {
        return true;
    }

    int insertedRows = postViewHistoryRepository.insertIfAbsent(
        postId, viewerType.name(), viewerId, now
    );

    return insertedRows == 1;
}

여기서 false는 단순 실패가 아닙니다. 1분 내 중복 조회이거나, 같은 시점에 들어온 다른 요청이 먼저 INSERT 또는 UPDATE에 성공해 이번 요청은 집계하지 않아야 한다는 의미입니다.

정책 전체를 상세 조회 흐름에 연결하기

컨트롤러에서는 인증 정보가 있으면 회원으로, 없으면 비회원으로 조회자를 식별합니다. 비회원에게 visitorId 쿠키가 없다면 새 UUID를 발급합니다. 토큰이 전달됐지만 유효하지 않은 경우에는 비회원으로 처리하지 않고 401을 반환합니다.

전체 조회수 처리 흐름
게시글 상세 조회 요청게시글이 없으면 404를 반환하고, 존재할 때만 인증 정보를 확인합니다.
Authorization 토큰 확인
토큰 없음 · 비회원
visitorId 쿠키 확인없다면 UUID를 생성해 새 쿠키를 발급합니다.
VISITOR / visitorId쿠키의 식별자로 조회 기록을 처리합니다.
토큰 있음 · 회원
토큰 검증검증에 실패하면 비회원으로 처리하지 않고 401을 반환합니다.
검증 성공
게시글 작성자인가?
작성자조회수 증가 없음
비작성자USER / userId
비회원과 비작성자 회원의 조회 기록 처리
1분이 지난 조회 기록만 조건부 UPDATEpostId · viewerType · viewerId가 같고 lastViewedAt이 기준 시각 이전인 행을 갱신합니다.
UPDATE 결과 행 수
UPDATE 1행
1분이 지난 재조회마지막 조회 시각 갱신 · 집계 가능
UPDATE 0행 · INSERT IGNORE 시도
INSERT 1행최초 조회 · 집계 가능
UNIQUE 충돌중복 또는 동시 요청 패배 · 집계 안 함
유효한 조회만
view_count를 DB에서 원자적으로 +1조회 기록 판정과 조회수 증가는 하나의 트랜잭션으로 처리합니다.
최신 게시글 조회 후 상세 응답벌크 UPDATE 이후 영속성 컨텍스트를 비우고 최신 조회수를 다시 읽습니다.
잘못된 토큰은 401로 종료
작성자와 중복 조회는 증가 없이 응답

조회 기록만 갱신되고 조회수 증가가 실패하거나, 그 반대의 상황이 생기지 않도록 조회 기록 판정과 조회수 원자적 증가는 게시글 상세 조회의 하나의 트랜잭션 안에서 실행했습니다. 유효한 조회로 판정된 경우에만 앞선 글에서 구현한 view_count = view_count + 1을 수행합니다.

벌크 UPDATE 이후에는 처음 조회한 Post 엔티티와 실제 DB 값이 달라질 수 있으므로 영속성 컨텍스트를 비운 뒤 최신 게시글을 다시 조회해 응답했습니다.

동일 사용자의 동시 요청으로 검증하기

같은 회원의 토큰으로 동일 게시글 상세 API에 요청 20개를 동시에 보냈습니다. 모든 요청이 같은 조회자 키를 사용하므로 정책상 조회수는 한 번만 증가해야 합니다.

xargs로 동일 회원의 게시글 상세 조회 요청 20개를 동시에 실행한 터미널
동일 회원으로 게시글 상세 요청 20개를 동시에 실행했습니다.
동시 요청 후 게시글 조회수와 조회 기록이 각각 한 번만 반영된 데이터베이스 결과
20개 요청 이후에도 조회수는 1, 해당 회원의 조회 기록은 1행만 반영됐습니다.

결과는 게시글 조회수 1, 해당 회원의 조회 기록 1행이었습니다. 복합 UNIQUE 제약과 조건부 연산이 동시에 들어온 요청 중 하나만 유효 조회로 판정한 것을 확인했습니다.

회원 최초 조회와 1분 내 재조회, 1분이 지난 재조회, 작성자 조회도 각각 확인했습니다. 비회원은 visitorId 최초 발급과 같은 쿠키를 사용한 중복 조회, 쿠키 삭제 후 새 visitorId 발급까지 검증했습니다.

원자적 증가는 숫자에만 필요한 것이 아니었다

Lost Update 문제에서는 숫자를 어떻게 안전하게 증가시킬지가 핵심이었습니다. 이번 문제에서는 그 숫자를 증가시켜도 되는 요청인지 어떻게 안전하게 결정할지가 핵심이었습니다.

Redis의 SET NX + TTL도 이 정책과 잘 맞지만, 현재 단계에서는 이미 운영 중인 MySQL에 테이블 하나를 추가하는 편이 구조와 비용 면에서 단순했습니다. 트래픽이 커져 조회 기록 테이블 접근이 실제 병목으로 확인된다면 Redis 방식의 성능과 동기화 범위를 측정한 뒤 전환할 수 있습니다.

정책을 코드로 옮길 때 단순히 조건문을 추가하는 것만으로는 충분하지 않았습니다. 조건을 확인하는 순간에도 다른 요청이 개입할 수 있다는 점을 발견하고, 데이터베이스의 조건부 UPDATE 결과와 UNIQUE 제약을 판정 수단으로 사용하면서 정책과 동시성 제어를 같은 구조 안에 담을 수 있었습니다.