[클론 코딩] 네이버 카페 - (통합게시판) 게시글 읽기

2024. 6. 1. 21:30Project/Naver Cafe

  이번에는 게시글을 읽을 수 있도록 게시글의 정보를 전달하는 기능을 구현하고자 한다. 기본적으로 DB에서 정보를 찾아 응답 DTO 객체를 생성하겠지만 이번엔 주의할 것이 있다. 바로 '동시성 문제'를 해결하는 것인데, 해당 문제가 발생할 부분은 여럿 있지만 그 중에 '게시글 조회수'에 대한 문제를 염두해두고 문제를 방지할 수 있도록 코드를 작성할 생각이다.

 

  조회수의 경우 생길 수 있는 동시성 문제는 다음과 같다. 게시글을 읽는 기능은 DB에서 게시글 정보를 조회하고, 조회수를 증가시키고 DB에 반영 게시글 정보를 객체에 담아 반환하는 식의 과정으로 진행될 것이다. 하지만 다른 두 사용자가 거의 동시에 미세한 차이로 같은 게시글을 읽을 경우 같은 정보를 조회하고 수정하면 데이터에 손실이 생기는 것이다. 거의 동시라고 해도 2명의 사용자가 읽었다면 최종적으로 조회수는 '2'여야 하나, 손실이 생겨 조회수가 '1'이 되는 것이다.


Normal

package CloneCoding.NaverCafe.domain.article.normal;

@Entity
@Table(name = "NORMAL_ARTICLE")
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Normal {

    public void addViewCount() {
        this.viewCount++;
    }

}

 

  • addViewCount() : viewCount의 값을 '1' 증가시킨다.

ResponseReadNormal

package CloneCoding.NaverCafe.domain.article.normal.dto;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ResponseReadNormal {

    private Long menuId;

    private String menuName;

    private String title;

    private String profileImage;

    private String accountId;

    private String nickname;

    private String position;

    private LocalDateTime createAt;

    private int viewCount;

    private int commentCount;

    private String articleUrl;

    private String body;

    private int favoriteCount;

    private String commentNickname;

    private String defaultComment;

}

 

  게시글을 읽을 때 필요한 정보들을 전달하는 DTO 클래스이다. 필요한 정보는 실제 네이버 카페 게시글을 참고해 작성해 보았다.


NormalController

package CloneCoding.NaverCafe.domain.article.normal.controller;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/cafe/{cafe_url}")
public class NormalController {

    private final NormalService normalService;

    @GetMapping("/read/{normal_id}")
    public ResponseReadNormal readArticle(@PathVariable("cafe_url") String url,
                                          @PathVariable("normal_id") Long id,
                                          @RequestHeader("Authorization") String token) {
        log.info("게시글 읽기 요청");
        return normalService.readNormal(url, id, token);
    }

}

 

  • readArticle() : 게시글 id, 카페 url, 토큰 정보로 NormalService의 readNormal()를 호출하고 결과 값을 반환

NormalServiceImpl

package CloneCoding.NaverCafe.domain.article.normal.service;

import static CloneCoding.NaverCafe.domain.article.normal.enums.BasicData.DEFAULT_ARTICLE_URL;
import static CloneCoding.NaverCafe.domain.article.normal.enums.BasicData.LEAVE_COMMENT;
import static CloneCoding.NaverCafe.domain.cafeMember.enums.CafeMemberPosition.changeNameToPosition;
import static CloneCoding.NaverCafe.message.SystemMessage.WRITE_COMPLETE;

@Service
@RequiredArgsConstructor
public class NormalServiceImpl implements NormalService {

    private final NormalRepository normalRepository;
    private final CafeRepository cafeRepository;
    private final CafeMemberRepository cafeMemberRepository;
    private final IntegrateRepository integrateRepository;
    private final AesUtil aesUtil;

    @Override
    @Transactional
    public ResponseReadNormal readNormal(String url, Long id, String token) {

        CafeMember reader = checkAuth(url, token);

        Normal article = normalRepository.findByIdWithLock(id)
                .orElseThrow(() -> new NoSuchElementException("게시글 정보를 찾을 수 없습니다."));

        Integrate menu = integrateRepository.findById(article.getMenuId())
                .orElseThrow(() -> new NoSuchElementException("게시판 정보를 찾을 수 없습니다."));

        CafeMember writer = cafeMemberRepository.findByAccountId(article.getCafeId(), article.getAccountId());

        article.addViewCount();

        // 동시성 문제 테스트를 위해 필요한 딜레이
        try {
            Thread.sleep(5000L);
        } catch (Exception e) {
            throw new RuntimeException("기다리는 중");
        }

        Normal updateNormal = normalRepository.save(article);

        return ResponseReadNormal.builder()
                .menuId(menu.getId())
                .menuName(menu.getName())
                .title("[" + article.getTitleHeader() + "] " + article.getTitle())
                .profileImage(writer.getProfileImage())
                .accountId(article.getAccountId())
                .nickname(article.getNickname())
                .position(changeNameToPosition(writer.getPosition()))
                .createAt(article.getCreateAt())
                .viewCount(updateNormal.getViewCount())
                .commentCount(article.getCommentCount())
                .articleUrl(DEFAULT_ARTICLE_URL.getValue() +
                        "/" + article.getCafeId().getUrl() +
                        "/" + article.getId())
                .body(article.getBody())
                .favoriteCount(article.getFavoriteCount())
                .commentNickname(reader.getNickname())
                .defaultComment(LEAVE_COMMENT.getValue())
                .build();
    }

    private CafeMember checkAuth(String url, String token) {
        Cafe cafe = cafeRepository.findByUrl(url);
        String accountId = aesUtil.aesDecode(token);

        return cafeMemberRepository.findByAccountId(cafe, accountId);
    }

}

 

  요청은 순식간에 완료 되므로 수정 엔티티를 저장하기 전에 수행을 지연시키는 Thread.sleep()을 사용하였다. 이걸 이용해 API TEST시 지정한 시간 안에 같은 요청을 2번 보내 결과를 확인할 생각이다.

  • readNormal() : ResponseReadNormal 객체 생성에 필요한 정보를 조회 후, 게시글의 viewCount를 '1' 증가 시키고 엔티티 저장(수정), 생성한 객체를 반환

NormalRepository

package CloneCoding.NaverCafe.domain.article.normal.repository;

import java.util.Optional;

public interface NormalRepository extends JpaRepository<Normal, Long>, QueryNormalRepository {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select n from Normal n where n.id = :id")
    Optional<Normal> findByIdWithLock(Long id);

}

 

  동시성 문제를 방지하기 위해서 아래의 메서드에 @Lock 어노테이션을 적용 '비관적 락'을 추가하였다. PESSIMISTIC_WRITE 옵션을 사용했으며, 해당 옵션은 DB의 select for update를 사용해 락을 걸고 다른 트랜잭션이 수정을 할 수 없도록 한다.

  • findByIdWithLock() : id 값을 통해 Normal 엔티티를 조회 후, Optional<Normal> 객체로 반환

API TEST

API TEST - 일반 게시판 읽기 첫 번째 요청

 

API TEST - 일반 게시판 읽기 두 번째 요청

 

  지연 속도 안에 같은 게시글을 읽는 요청을 2번 수행하였다. 만약 비관적 락을 사용하지 않은 findById()를 사용했다면 두 요청의 조회수 결과는 둘 다 '1'일 것이다. 하지만 비관적 락을 사용해 두 번째 요청은 수정을 할 수 없어 대기하게 되고 첫 번째 요청이 완료된 후 두 번째 요청이 수행되어 첫 번째 요청은 '1', 두 번째 요청은 '2'의 값을 조회수로 반환하게 되었다.

 

DB - 일반 게시판 읽기 이후 결과

 

  DB를 확인해 봐도 최종적으로 조회수가 '2'로 반영된 것을 확인 할 수 있다.