조회수(viewCount) 동시성 문제 해결 (JPA + Querydsl 프로젝트)
1️⃣ 목표
- 게시글 상세 조회 시 조회수(viewCount)가 1 증가하도록 구현한다.
- 동시에 여러 사용자가 같은 게시글을 조회해도 조회수 누락 없이 정확히 누적되도록 만든다.
- 멀티스레드 테스트로 “동시성 안전”을 증명한다.
2️⃣ 왜 동시성 문제가 생기나?
가장 단순한 구현은 이런 식이다:
- 게시글 조회
- viewCount = viewCount + 1
- 저장
하지만 동시에 요청이 들어오면(예: 2명이 거의 동시에 조회)
- 두 요청 모두 “현재 조회수 10”을 읽고
- 둘 다 11로 저장해버려서
- 실제 조회는 2번인데 DB에는 11만 남는 Lost Update가 발생할 수 있다.
3️⃣ 해결 전략 선택
조회수는 트래픽이 많고 “정합성 + 성능”이 중요하다.
- 비관적 락(FOR UPDATE): 확실하지만 대기시간이 길어질 수 있음
- 낙관적 락(@Version + 재시도): 충돌이 잦으면 예외/재시도 비용이 커질 수 있음
- ✅ 원자적 UPDATE 쿼리(view_count = view_count + 1): 조회수 집계에 가장 많이 쓰는 실전 방식
따라서 조회수 증가를 “엔티티 변경 감지”가 아니라 DB에서 한 번에 +1 하는 방식으로 해결한다.
4️⃣ 해결 과정
1) 엔티티 수정: viewCount 추가 (null 방지)
조회수는 null이면 안 되므로 Long 대신 long을 사용하고 기본값 0을 준다.
@Entity
@Getter
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "ARTICLE")
public class Article {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", updatable = false)
private Long id;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "content", nullable = false)
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private User author;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
// ✅ 조회수: null 방지 + 기본 0 + 컬럼명 명시
@Column(name = "view_count", nullable = false)
private long viewCount = 0L;
@Builder
public Article(User author, String title, String content) {
this.author = author;
this.title = title;
this.content = content;
}
public void update(String title, String content){
this.title = title;
this.content = content;
}
}
🚨 자주 만나는 에러
nullable = false로 바꿨는데 기존 DB에 NULL이 들어가 있으면 다음 에러가 난다:
NULL not allowed for column "VIEW_COUNT"
개발(H2) 환경이라면 ddl-auto: create로 테이블을 재생성하면 해결된다.
2) Repository 추가: 조회수 원자적 증가 쿼리
UPDATE... SET view_count = view_count + 1 형태의 JPQL을 사용한다.
public interface BlogRepository extends JpaRepository<Article, Long>, BlogRepositoryCustom {
@Override
@EntityGraph(attributePaths = {"author"})
Page<Article> findAll(Pageable pageable);
@Modifying(clearAutomatically = true)
@Query("update Article a set a.viewCount = a.viewCount + 1 where a.id = :id")
int incrementViewCount(@Param("id") Long id);
}
왜 clearAutomatically = true를 쓰나?
JPQL update는 영속성 컨텍스트(1차 캐시)를 우회해서 DB를 직접 수정한다.
그래서 업데이트 후 같은 트랜잭션에서 엔티티를 다시 조회하면, 캐시에 남아있는 “이전 값”이 튀어나올 수 있다.
clearAutomatically = true는
업데이트 후 1차 캐시를 비워서 다음 조회에서 DB 값을 다시 읽도록 만든다.
3) Service 설계: “상세조회(조회수 증가)”와 “그 외 조회(증가 없음)” 분리
조회수는 “진짜 상세보기”에서만 올라가야 한다.
수정 폼 조회나 내부 검증에서 조회수가 오르면 이상해지기 때문에 메서드를 분리한다.
@RequiredArgsConstructor
@Service
public class BlogService {
private final BlogRepository blogRepository;
private final UserRepository userRepository;
public Article save(AddArticleRequest request, String userName){
User user = userRepository.findByEmail(userName)
.orElseThrow(() -> new IllegalArgumentException("not found: " + userName));
return blogRepository.save(request.toEntity(user));
}
public Page<Article> findAll(Pageable pageable){
return blogRepository.findAll(pageable);
}
public Page<Article> search(ArticleSearchCondition condition, Pageable pageable) {
return blogRepository.search(condition, pageable);
}
// ✅ 조회만 (수정/삭제/내부 검증용): 조회수 증가 없음
public Article getArticle(Long id){
return blogRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("not found : " + id));
}
// ✅ 상세 조회 전용: 조회수 증가 포함
@Transactional
public Article viewArticle(Long id){
int updated = blogRepository.incrementViewCount(id);
if(updated == 0){
throw new IllegalArgumentException("not found : " + id);
}
return blogRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("not found : " + id));
}
public void delete(Long id){
Article article = getArticle(id); // ✅ 조회수 증가 없음
authorizeArticleAuthor(article);
blogRepository.delete(article);
}
@Transactional
public Article update(long id, UpdateArticleRequest request){
Article article = getArticle(id); // ✅ 조회수 증가 없음
authorizeArticleAuthor(article);
article.update(request.getTitle(), request.getContent());
return article;
}
private static void authorizeArticleAuthor(Article article){
String userName = SecurityContextHolder.getContext().getAuthentication().getName();
if(!article.getAuthor().getEmail().equals(userName)){
throw new IllegalArgumentException("not authorized");
}
}
}
updated == 0 의미
updated는 “조회수 값”이 아니라 UPDATE 된 행(row) 수이다.
- 게시글 존재 → 1 row 업데이트됨 → updated = 1
- 게시글 없음 → 0 row 업데이트됨 → updated = 0 → not found 처리
4) Controller 연결: “상세 조회만 viewArticle 호출”
4-1) REST API 상세조회 /api/articles/{id}
상세 조회이므로 조회수가 올라가야 한다.
@GetMapping("/api/articles/{id}")
public ResponseEntity<ArticleResponse> findArticle(@PathVariable long id){
Article article = blogService.viewArticle(id); // ✅ 조회수 증가
return ResponseEntity.ok().body(new ArticleResponse(article));
}
4-2) 화면 상세조회 /articles/{id}
상세 페이지이므로 조회수가 올라가야 한다.
@GetMapping("/articles/{id}")
public String getArticle(@PathVariable Long id, Model model) {
Article article = blogService.viewArticle(id); // ✅ 조회수 증가
...
model.addAttribute("article", new ArticleViewResponse(article));
return "article";
}
4-3) 수정 폼 /new-article?id=...
편집용 조회이므로 조회수가 올라가면 안 된다.
@GetMapping("/new-article")
public String newArticle(@RequestParam(required = false) Long id, Model model){
if(id == null){
model.addAttribute("article", new ArticleViewResponse());
} else {
Article article = blogService.getArticle(id); // ✅ 조회수 증가 없음
model.addAttribute("article", new ArticleViewResponse(article));
}
return "newArticle";
}
5) View(Thymeleaf)에서 조회수 표시
상세 페이지에서 조회수를 보여주려면 템플릿에 출력 영역을 추가한다.
<span class="meta-divider">|</span>
<span>
<span class="meta-label">조회:</span>
<span th:text="${article.viewCount}">0</span>
</span>
주의: article이 DTO(ArticleViewResponse)면 DTO에 viewCount 필드도 추가해야 한다❗️
5️⃣ 동시성 테스트 (핵심 증명)
테스트 의도
- 동시에 100번 viewArticle()을 호출했을 때
- 최종 조회수가 정확히 100이 되는지 검증한다.
테스트 코드 (BlogServiceTest)
@SpringBootTest
class BlogServiceTest {
@Autowired private BlogService blogService;
@Autowired private BlogRepository blogRepository;
@Autowired private UserRepository userRepository;
@Autowired private EntityManager em;
@AfterEach
void tearDown(){
blogRepository.deleteAllInBatch();
userRepository.deleteAllInBatch();
}
@Test
@DisplayName("조회수 단건 증가가 정상 동작한다.")
void viewCount_increase_once(){
User user = userRepository.save(
User.builder()
.email("test@test.com")
.password("pw")
.nickname("nick")
.build()
);
Article article = blogRepository.save(
Article.builder()
.author(user)
.title("title")
.content("content")
.build()
);
Long articleId = article.getId();
blogService.viewArticle(articleId);
em.clear();
Article updated = blogRepository.findById(articleId).orElseThrow();
assertThat(updated.getViewCount()).isEqualTo(1L);
}
@Test
@DisplayName("조회수 동시성 테스트")
void viewCount_concurrency() throws Exception{
User user = userRepository.save(
User.builder()
.email("test@test.com")
.password("pw")
.nickname("nick")
.build()
);
Article article = blogRepository.save(
Article.builder()
.author(user)
.title("title")
.content("content")
.build()
);
Long articleId = article.getId();
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(20);
CountDownLatch latch = new CountDownLatch(threadCount);
for(int i = 0; i < threadCount; i++){
executorService.submit(() -> {
try{
blogService.viewArticle(articleId);
} finally {
latch.countDown();
}
});
}
boolean finished = latch.await(10, TimeUnit.SECONDS);
executorService.shutdown();
assertThat(finished).isTrue();
em.clear();
Article updated = blogRepository.findById(articleId).orElseThrow();
assertThat(updated.getViewCount()).isEqualTo(threadCount);
}
}
테스트 코드 동작 요약
- ExecutorService: 스레드풀로 동시 요청 환경 생성
- CountDownLatch: 100개 작업이 끝날 때까지 메인 스레드가 기다림
- 마지막에 DB에서 다시 조회(em.clear()로 캐시 제거 후)해서 조회수가 정확히 누적됐는지 검증
결과
- 단건 조회 시 조회수가 1 증가하는 것을 확인했다.
- 100개 동시 요청 환경에서도 조회수가 누락 없이 정확히 100으로 증가하는 것을 테스트로 증명했다.
- 락을 걸어 대기시키는 방식이 아니라, DB의 원자적 업데이트를 사용해 성능과 정합성을 함께 확보했다.
'프로젝트 > 스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트' 카테고리의 다른 글
| 👥🚨 좋아요 토글을 빠르게 연타하면 깨지는 이유와 해결 방법 (프론트 + 백엔드 동시성 정리) (0) | 2026.02.14 |
|---|---|
| 👥💡 원자적 방법이란? 벌크성 내용이랑 무슨 차이가 있는지? (0) | 2026.02.14 |
| 👥💡 Spring Data JPA + Querydsl 커스텀 Repository 구조 정리 (0) | 2026.02.14 |
| 👥💡 Querydsl 페이징: fetchResults()가 비권장(Deprecated)된 이유와 실무 정석 패턴 (0) | 2026.02.14 |
| 👥🚨 수정시간 및 수정하기 작성자 권한 변경 (0) | 2026.02.14 |