👥💡 조회수 동시성 문제 해결(원자적)

2026. 2. 14. 17:48·프로젝트/스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트

조회수(viewCount) 동시성 문제 해결 (JPA + Querydsl 프로젝트)

1️⃣ 목표

  • 게시글 상세 조회 시 조회수(viewCount)가 1 증가하도록 구현한다.
  • 동시에 여러 사용자가 같은 게시글을 조회해도 조회수 누락 없이 정확히 누적되도록 만든다.
  • 멀티스레드 테스트로 “동시성 안전”을 증명한다.

2️⃣ 왜 동시성 문제가 생기나?

가장 단순한 구현은 이런 식이다:

  1. 게시글 조회
  2. viewCount = viewCount + 1
  3. 저장

하지만 동시에 요청이 들어오면(예: 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
'프로젝트/스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트' 카테고리의 다른 글
  • 👥🚨 좋아요 토글을 빠르게 연타하면 깨지는 이유와 해결 방법 (프론트 + 백엔드 동시성 정리)
  • 👥💡 원자적 방법이란? 벌크성 내용이랑 무슨 차이가 있는지?
  • 👥💡 Spring Data JPA + Querydsl 커스텀 Repository 구조 정리
  • 👥💡 Querydsl 페이징: fetchResults()가 비권장(Deprecated)된 이유와 실무 정석 패턴
hak0622
hak0622
개발하면서 “이게 뭐지?”라는 순간마다 궁금한 점을 바탕으로 정리한 개발 블로그입니다.
  • hak0622
    궁금한 개발 이야기 Why?
    hak0622
  • 전체
    오늘
    어제
    • 분류 전체보기 (68)
      • 공부 (36)
        • 1. 자바 ORM 표준 JPA 프로그래밍 - 기본.. (35)
        • 시험 (1)
      • 프로젝트 (32)
        • 스프링 부트 3 백엔드 개발자 되기 Blog + .. (32)
  • 인기 글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
hak0622
👥💡 조회수 동시성 문제 해결(원자적)
상단으로

티스토리툴바