
앞선 글에서 조회수 증가를 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를 적용했습니다.

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을 반환합니다.
조회 기록만 갱신되고 조회수 증가가 실패하거나, 그 반대의 상황이 생기지 않도록 조회 기록 판정과
조회수 원자적 증가는 게시글 상세 조회의 하나의 트랜잭션 안에서 실행했습니다. 유효한 조회로 판정된
경우에만 앞선 글에서 구현한 view_count = view_count + 1을 수행합니다.
벌크 UPDATE 이후에는 처음 조회한 Post 엔티티와 실제 DB 값이 달라질 수 있으므로 영속성 컨텍스트를 비운 뒤 최신 게시글을 다시 조회해 응답했습니다.
동일 사용자의 동시 요청으로 검증하기
같은 회원의 토큰으로 동일 게시글 상세 API에 요청 20개를 동시에 보냈습니다. 모든 요청이 같은 조회자 키를 사용하므로 정책상 조회수는 한 번만 증가해야 합니다.


결과는 게시글 조회수 1, 해당 회원의 조회 기록 1행이었습니다. 복합 UNIQUE 제약과 조건부 연산이 동시에 들어온 요청 중 하나만 유효 조회로 판정한 것을 확인했습니다.
회원 최초 조회와 1분 내 재조회, 1분이 지난 재조회, 작성자 조회도 각각 확인했습니다. 비회원은 visitorId 최초 발급과 같은 쿠키를 사용한 중복 조회, 쿠키 삭제 후 새 visitorId 발급까지 검증했습니다.
원자적 증가는 숫자에만 필요한 것이 아니었다
Lost Update 문제에서는 숫자를 어떻게 안전하게 증가시킬지가 핵심이었습니다. 이번 문제에서는 그 숫자를 증가시켜도 되는 요청인지 어떻게 안전하게 결정할지가 핵심이었습니다.
Redis의 SET NX + TTL도 이 정책과 잘 맞지만, 현재 단계에서는 이미 운영 중인 MySQL에 테이블 하나를
추가하는 편이 구조와 비용 면에서 단순했습니다. 트래픽이 커져 조회 기록 테이블 접근이 실제 병목으로
확인된다면 Redis 방식의 성능과 동기화 범위를 측정한 뒤 전환할 수 있습니다.
정책을 코드로 옮길 때 단순히 조건문을 추가하는 것만으로는 충분하지 않았습니다. 조건을 확인하는 순간에도 다른 요청이 개입할 수 있다는 점을 발견하고, 데이터베이스의 조건부 UPDATE 결과와 UNIQUE 제약을 판정 수단으로 사용하면서 정책과 동시성 제어를 같은 구조 안에 담을 수 있었습니다.