단순 조회수 증가 기능

요구사항

  • 게시글을 조회할 때마다 조회수가 1씩 증가함

  • 구현시 고려사항

1. 이전 코드

(1) 구현 로직 흐름

  • Get 메소드 내부에서 조회수를 가져와서 +1 한 뒤 다시 업데이트하는 로직을 추가하는 방식
    • (1) GetPostController가 호출됨
    • (2) 해당 Id의 Post를 DB에서 조회함
    • (3) 조회수 증가 로직 : 가져온 Post의 현재 ViewCount에서 +1 증가시킨 후 update함
    • (4) 조회한 Post의 결과는 Response로 전달

(2) 구현 로직 예시

@Getter 
@NoArgsConstructor(access = PROTECTED)
public class Post extends BaseTimeEntity {

    private Long id;
    private String title;
    private String content;
    private Member author;
    private Long viewCount;
    // 일부 생략

    @Builder
    public Post(String title, String content, Member author) {
        this.title = title;
        this.content = content;
        this.author = author;
        this.viewCount = 0L;
    }
    
    public void updateViewCount(){
	    viewCount++;
    }
}    

(3) 문제 발생 : 동시성 이슈

동시성 이슈 발생

  • 사용자가 동시에 조회할 경우 조회한 만큼 조회수에 반영되지 않음

  • 다수의 사용자가 동시에 같은 게시글에 접근하는 경우, 두 개 이상의 요청이 거의 동시에 발생할 수 있음. 이때, 두 요청이 모두 같은 조회수를 가져와서 +1을 하고 업데이트 한다면 조회수가 1만 증가함.

image.png

2. 리팩토링 : 동시성 고려

해결 방안 : Upadate문 활용

  • UPDATE 문을 사용하여 데이터베이스에서 직접 viewCount를 1 증가시키는 방법

  • Update 쿼리 실행이 단일 SQL 문으로 이루어지기 때문에, DB가 동시성 제어를 관리하여 여러 요청이 동시에 발생하더라도 안전하게 처리가능

(1) 구현 로직 예시

  • updateViewCount() 삭제
  • id가 특정 값인 게시글의 viewCount를 한 번에 1 증가 쿼리 추가
@Getter 
@NoArgsConstructor(access = PROTECTED)
public class Post extends BaseTimeEntity {

    private Long id;
    private String title;
    private String content;
    private Member author;
    private Long viewCount;
    // 일부 생략

    @Builder
    public Post(String title, String content, Member author) {
        this.title = title;
        this.content = content;
        this.author = author;
        this.viewCount = 0L;
    }
} 
public interface PostMapper extends CrudMapper<Post> {
		// 코드 생략
    @Update("UPDATE post SET view_count = view_count + 1 WHERE id = #{id}")
    void increaseViewCount(Long id);
}    

(2) 해결 방안 상세 설명

  • UPDATE 쿼리를 통해 동시성 문제 해결 가능한 이유?
    • Atomicity (원자성) UPDATE문은 DB의 원자적 연산에 해당함. 따라서, 두 개 이상의 트랜잭션이 동시에 동일한 행을 수정하려고 할 때, DB는 각 트랜잭션이 완료되기 전까지 다른 트랜잭션이 그 행을 수정하지 못하게 Lock을 건다.
    • Locking Mechanism 대부분의 관계형 데이터베이스는 Update문 실행시 해당 행에 대해 행 잠금(row lock) 을 적용함. 이로 인해, 하나의 트랜잭션이 완료되기 전에는 다른 트랜잭션이 동일한 행의 viewCount를 변경하지 못하게 됨.

(3) 해결 방안 단점 : DB 성능 문제

데이터베이스 성능 문제

  • 이 방법은 대부분 문제가 없지만, 매우 트래픽이 많은 환경에서는 성능에 영향을 줄 수 있음

  • 매번 조회할 때마다 DB에 읽기 및 쓰기 작업이 발생하기 때문에 데이터베이스에 과부하를 줄 수 있음

  • 내부적으로 발생하는 데이터베이스 잠금이 성능에 영향을 줄 수 있음

(4) DB 성능 문제 해결 방안 : 캐시 활용

아이디어 도출

  • 일시적으로 조회수를 저장하고 일정 주기마다 (예: 5분마다, 혹은 N번 증가할 때마다) DB에 저장하는 방식

  • ⚠️  데이터 일관성 문제 발생

3. 결론

  • 캐시 활용없이 Update 쿼리문으로 해결
    • 현재 기획상 실시간으로 조회수가 반영되어야 함
    • 하루 평균 조회수가 50을 넘지 않을 것으로 보여 DB 성능 문제가 크게 이슈 없다고 판단

조회수 기능 : 추가 요구사항 적용

요구사항

  • 사용자가 게시물을 조회할 때마다 해당 게시글의 조회수 증가

  • 동일한 사용자가 동일한 게시물의 조회수를 최대 하루당(자정 기준) 1회 증가 시킬 수 있음

  • 구현시 고려사항 : 사용자의 조회 기록을 저장하고 확인 필요

1. 이전 코드

(1) 조회 기록 테이블 구조

  • 조회 기록 테이블 : 사용자가 조회할 때마다 모든 조회 내역을 저장함
CREATE TABLE post_view_log (
    id             GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    user_id        NUMBER NOT NULL,
    post_id        NUMBER NOT NULL,
    view_date      DATE NOT NULL
); 

(2) 쿼리 구현 예시

  • 게시글 Id 기준 총 조회수 계산하는 쿼리 활용
  • view_date, user_id, post_id의 세 가지 값이 동일한 경우, 1회로 간주해 조회수를 계산하는 방식
-- 다건 조회 
SELECT post_id, COUNT(*) AS view_count
FROM (
    SELECT DISTINCT post_id, user_id, view_date
    FROM post_view_log
) t
GROUP BY post_id;

-- 단건 조회
SELECT COUNT(*) AS view_count
FROM (
    SELECT DISTINCT post_id, user_id, view_date
    FROM post_view_log
) t
WHERE post_id = :specific_post_id;

(3) 문제 발생 : 조회 성능 ↓

데이터베이스 성능 문제

  • 조회할 때마다 전체 데이터의 count 쿼리하는 방식은 비효율적

  • 데이터량이 많아질 경우 조회 속도 측면에서 문제 발생

2. 리팩토링 : 유니크 제약조건을 활용한 INSERT 방식

구현 아이디어

  • 사용자 Id, 게시글 Id, 조회 일자 기준 유니크 제약 조건 설정

  • 조회 로그 삽입 성공시 조회수 증가시킴

  • 중복 삽입 시도시 예외 처리를 통해 조회수 증가 방지

(1) 조회 기록 테이블 구조

  • 각 사용자의 게시물 조회 기록을 저장
  • 동일한 사용자가 동일한 게시물에 대해 최대 하루당 한번 조회 기록을 저장함
  • user_id , post_id , view_date유니크 제약조건 설정
CREATE TABLE post_view_log (
    id             GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    user_id        NUMBER NOT NULL,
    post_id        NUMBER NOT NULL,
    view_date      DATE NOT NULL,
    created_at     TIMESTAMP,
    updated_at     TIMESTAMP,

    CONSTRAINT uq_view_log UNIQUE (user_id, post_id, view_date)
); 

(2) 구현 로직 흐름

  • PostId에 해당하는 게시물을 가져오고, 조회수를 증가시키는 기능
    • 사용자가 해당 게시물 조회 여부를 사전에 조회한 후, count를 올릴 경우 동시성 문제가 발생할 수 있음
    • 조회 기록 테이블(post_view_log)에서 유니크 제약 조건을 설정하였으므로, 중복 삽입 시도했을 때 예외를 처리하는 방식으로 구현
      • INSERT를 시도한 후 중복 예외를 처리하면 동시성 문제가 발생하지 않음
      • 여러 트랜잭션이 동시에 동일한 데이터를 삽입하려 할 경우 한 트랜잭션만 성공하고 나머지는 실패하기 때문

(3) 구현 로직 예시

  • handleViewLogAndIncreaseCount 역할
    • 조회 로그를 관리하고, 사용자가 오늘 해당 게시물을 조회한 적이 없는 경우 조회수를 증가시킴
    • 로직 상세 설명
      • 현재 날짜로 PostViewLog 객체를 생성
      • 조회 로그를 삽입하고, 조회수를 증가시키려고 시도
      • PersistenceException 예외를 캐치하여 사용자가 이미 오늘 해당 게시물을 조회한 경우 조회수 증가를 방지함
      • 단, 예외가 고유 제약 조건 위반으로 인한 것인지 확인 후 예외 처리
public class PostService {		
		
		private final PostRepository postRepository;
		private final PostViewLogRepository postViewLogRepository;
		
		// 생략

		// 주어진 postId로 게시물 조회
		public Post findByIdWithIncreaseViewCount(Long postId, Long userId) {
        handleViewLogAndIncreaseCount(postId, userId);
        return postRepository.findById(postId);
    }    

		// 조회 로그 관리 및 조회수 증가
    private void handleViewLogAndIncreaseCount(Long postId, Long userId) {
		    LocalDate today = LocalDate.now();
        PostViewLog postViewLog = new PostViewLog(postId, userId, today);
        try {
            insertViewLogAndIncreaseCount(postId, userId, postViewLog);
        } catch (PersistenceException e) { // 이미 해당 유저가 오늘 조회기록이 있을 경우 조회수 증가 방지
            if (!isUniqueConstraintViolation(e)) { // 고유 제약 조건 위반 여부를 확인
                throw new BusinessException(PostErrorCode.POST_DATABASE_ERROR);
            }
        }
    }
    
    private void insertViewLogAndIncreaseCount(Long postId, Long userId, PostViewLog postViewLog) {
        postViewLogRepository.insertViewLog(postViewLog); // 조회 로그 삽입
        postRepository.increaseViewCount(id); // 조회수 증가
    }
    
    private boolean isUniqueConstraintViolation(PersistenceException e) {
        return e.getCause() instanceof SQLIntegrityConstraintViolationException;
    }

}
public class PostRepository {
    private final PostMapper postMapper;
    
    public PostRepository(SqlSession sqlSession) { this.postMapper = sqlSession.getMapper(PostMapper.class); }	

    public Post findById(Long id) {
        return postMapper.selectById(id).orElseThrow(() -> new BusinessException(PostErrorCode.NOT_FOUND_POST));
    }

    private void increaseViewCount(Long id) {
        postMapper.increaseViewCount(id);
    }

}
public class PostViewLogRepository {
    private final PostViewLogMapper postViewLogMapper;

    public PostViewLogRepository(SqlSession sqlSession) {
        this.postViewLogMapper = sqlSession.getMapper(PostViewLogMapper.class);
    }

    public void insertViewLog(PostViewLog postViewLog) {
        postViewLogMapper.insert(postViewLog);
    }

}
public interface PostMapper extends CrudMapper<Post> {
    
    Member author = new Member();
    BasicColumn[] selectList = BasicColumn.columnList(
            id.as("post_id"),title, content
            author.Id.as("author_id"),author.userName.as("author_user_name"),
            viewCount,createdAt, updatedAt
    );

    @SelectProvider(type = SqlProviderAdapter.class, method = "select")
    @Results(id = "postResult", value = {
            @Result(column = "post_id", property = "id", id = true),
            @Result(column = "title", property = "title"),
            @Result(column = "content", property = "content"),
            @Result(column = "author_id", property = "author.Id"),
            @Result(column = "author_user_name", property = "author.userName"),
            @Result(column = "view_count", property = "viewCount"),
            @Result(column = "created_at", property = "createdAt"),
            @Result(column = "updated_at", property = "updatedAt")
    })
    Optional<Post> selectOne(SelectStatementProvider selectStatement);
    
    private Optional<Post> selectOne(SelectDSLCompleter completer) {
        QueryExpressionDSL<SelectModel> start = SqlBuilder.select(selectList)
                .from(post)
                .leftJoin(author, on(post.authorId, equalTo(author.postId)));
        return MyBatis3Utils.selectOne(this::selectOne, start, completer);
    }

		 default Optional<Post> selectById(Long id_) {
        return selectOne(c -> c.where(post.id, isEqualTo(id_)));
    }   			
	
    @Update("UPDATE post SET view_count = view_count + 1 WHERE id = #{id}")
    void increaseViewCount(Long id);
}

3. 리팩토링 : 날짜별 파티셔닝을 활용한 방식 구현

구현 아이디어

  • 파티셔닝을 사용하여 날짜별로 데이터를 분리

  • 과거 날짜에 대한 조회수는 스케쥴러를 통해 고정된 값으로 유지
    → 과거 날짜에 대한 조회수는 변하지 않으므로 미리 계산해두면 과거 조회수 계산에 대한 비용 줄일 수 있음

  • 오늘 날짜에 해당하는 view_log만 실시간으로 처리하는 방식
    → 날짜별로 데이터를 파티셔닝해두었기 때문에 특정 날짜에 해당하는 파티션만 조회 가능함

(1) 테이블 구조 및 설명

  • 조회 기록 테이블 : 날짜별로 데이터를 파티셔닝 추가
CREATE TABLE post_view_log (
    id             NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    user_id        NUMBER NOT NULL,
    post_id        NUMBER NOT NULL,
    view_date      DATE NOT NULL,
    created_at     TIMESTAMP,
    updated_at     TIMESTAMP
)
PARTITION BY RANGE (view_date) -- view_date 기준으로 파티션 나눔
INTERVAL (NUMTODSINTERVAL(1, 'DAY'))  -- 하루 단위로 파티션을 자동 생성
(
    PARTITION p_before_202408 VALUES LESS THAN (TO_DATE('2024-08-01', 'YYYY-MM-DD')) 
    -- 초기 파티션을 수동 정의 ( 2024-08-01 이전 데이터 )
);
  • 조회수 통계/집계(statistics) 테이블 : 각 게시물의 날짜별 총 조회수를 저장하는 테이블
CREATE TABLE post_view_statistics
(
    id         NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    post_id     NUMBER NOT NULL,
    view_date  DATE   NOT NULL,
    view_count NUMBER DEFAULT 0,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,

    CONSTRAINT uq_post_view_statistics UNIQUE (post_id, view_date)
);

(2) 구현 로직 및 예시

  • 각 게시글의 전날 총 조회수 조회 및 저장 기능
    • selectYesterdayViewCountGroupByPostId() → 어제 날짜의 게시물 조회 수를 PostId별로 그룹화하여 가져오는 SQL 쿼리 실행
public interface PostViewLogMapper extends CrudMapper<PostViewLog> {
		// 생략
    @Select(" SELECT post_id, count(*) AS view_count, TO_DATE(sysdate -1, 'YYYY-MM-DD') AS view_date 
					    FROM (SELECT DISTINCT post_id, user_id, view_date 
									    FROM post_view_log 
										  WHERE view_date = TO_DATE(sysdate -1, 'YYYY-MM-DD')
							) t 
							GROUP BY post_id")
    List<PostViewCountResult> selectYesterdayViewCountGroupByPostId();
}
public interface PostViewStatisticsMapper extends CrudMapper<PostViewStatistics> {
		// 생략    
    default List<PostViewStatistics> insertMultiple(List<PostViewStatistics> records) {
        MyBatis3Utils.insertMultiple(this::insertMultiple, records, PostViewStatisticsDynamicSqlSupport.postViewStatistics, 
	        c -> c.map(PostViewStatisticsDynamicSqlSupport.postId).toProperty("postId")
	              .map(PostViewStatisticsDynamicSqlSupport.viewCount).toProperty("viewCount")
	              .map(PostViewStatisticsDynamicSqlSupport.viewDate).toProperty("viewDate")
	              .map(PostViewStatisticsDynamicSqlSupport.createdAt).toProperty("createdAt")
	              .map(PostViewStatisticsDynamicSqlSupport.updatedAt).toProperty("updatedAt")
        );
        return records;
    }
}
public class PostViewRepository {
  private final PostViewLogMapper postViewLogMapper;
  private final PostViewStatisticsMapper postViewStatisticsMapper;

	// 생략

	public List<FaqViewStatistics> saveYesterdayPostViewCount() {
		  if (postViewStatisticsList != null && !postViewStatisticsList.isEmpty()) {
		    return postViewStatisticsMapper.insertMultiple(getYesterdayViewCountResults());
	    } else {
		    return Collections.emptyList();
	    }
	}
  
  public List<PostViewStatistics> getYesterdayViewCountResults() {
        List<PostViewCountResult> postViewCountResults = postViewLogMapper.selectYesterdayViewCountGroupByPostId();
        return PostViewCountResults.stream()
                .map(PostViewCountResult::toPostViewStatistics)
                .toList();
	}
}    
  • 스케쥴러 등록 : registerPostViewCountStatisticsScheduler 코드
    • RunScheduleInDto 객체를 생성하고, 필요한 스케줄러 정보를 설정함
      • 이름, 노드 ID, 시작 시간, 시간 단위, 실행 횟수, 간격, 그리고 타겟 서비스 이름 등 설정
      • TargetServiceName 설정
    • ServiceManager.getInstance().callAsync 메소드 통해 비동기 방식으로 스케줄러 등록함
      • 1st 인자: 스케줄러 호출 경로 설정
      • 2st 인자 : runScheduleInDto 객체 전달
      • 3st 인자 : 콜백 함수로, 응답을 로그에 기록함
      • 4st 인자 : 비동기 호출 여부를 true로 설정함
  • 로그 기록 : 스케줄러 정보와 등록 완료 메시지를 로그에 기록함
  • 스케쥴러 동작 방식 : 스케줄러 서비스에 스케줄을 등록하고, 지정된 간격에 따라 서비스를 트리거
    • 스케줄러 서비스는 RunScheduleInDto 객체에 있는 정보를 사용하여 언제, 얼마나 자주 지정된 서비스를 실행할지 결정함
    • 지정된 간격(1440분, 즉 24시간)마다 스케줄러는 setTargetServiceName에 지정된 서비스를 실행함
    • 스케줄러가 서비스를 트리거하면, 타겟 서비스 이름을 호출함
@Slf4j
public class SchedulerService extends AbstractServiceObject {

    @Override
    public void service(BodyObject bodyObject) throws Exception {
        registerPostViewCountStatisticsScheduler();
    }

    private void registerPostViewCountStatisticsScheduler() throws Exception {
        RunScheduleInDto runScheduleInDto = new RunScheduleInDto();
        runScheduleInDto.setName("INSERT_POST_VIEW_COUNT"); // 스케쥴러 이름 설정
        runScheduleInDto.setNodeId(Global.getInstance().getNID()); // 현재 노드의 ID 설정
        String nextScheduledTime = ScheduleUtils.getNextScheduledTime(LocalDateTime.now(), 0, 0, 0);
        runScheduleInDto.setStartedAt(nextScheduledTime); // 현재 시간으로부터 다음 스케줄 시간을 계산하여 설정
        runScheduleInDto.setTimeUnit("m"); // 시간 단위를 분("m")으로 설정
        runScheduleInDto.setRunningCount(-1); // 행 횟수를 무한(-1)으로 설정
        runScheduleInDto.setInterval(1440); // 실행 간격을 1440분(24시간)으로 설정

				// 실행할 서비스의 이름 설정 (현재 Application명 + 실행할 타겟 서비스)
        runScheduleInDto.setTargetServiceName(Global.currentApplicationName.get()
                + "/com.scheduler.faq.FaqViewCountStatisticsScheduler");
        ServiceManager.getInstance()
                .callAsync("0/" + AdminService.Schedule.CallSchedule, runScheduleInDto,
                        (response) -> log.info(String.valueOf(response)), true);

        log.info("[스케줄러] 등록 스케줄러 정보: {}", runScheduleInDto);
        log.info("[스케줄러] Post 조회수 통계 스케줄러 등록 완료");
    }
}
 public class PostViewCountStatisticsScheduler extends ServiceResponseOnly<Void> {
    @Override
    public Void doService(SqlSession sqlSession) {
        PostViewRepository postViewRepository = new PostViewRepository(sqlSession);
        postViewRepository.saveYesterdayPostViewCount();
        return null;
    }
}
  • 조회수 계산
    • 과거 데이터는 post_view_statistics 에 저장되어 있음
    • 오늘 날짜의 조회수는 post_view_log 에서 실시간으로 계산
public int getViewCount(Long postId) { 
    LocalDate today = LocalDate.now();
    
    // 과거 조회수 가져오기
    Integer pastViewCount = postViewStatsMapper.getTotalViewCountByPostId(postId);
    
    // 오늘의 조회수 계산 (실시간으로 로그 조회)
    Integer todayViewCount = postViewLogMapper.getTodayViewCount(postId, today);
    
    // 총 조회수 반환
    return (pastViewCount != null ? pastViewCount : 0) + (todayViewCount != null ? todayViewCount : 0);
}

스케쥴러

1. 스케줄러와 배치 개념

  • 스케줄러와 배치 처리는 자동화된 작업을 위한 중요 기술 → 자동화를 통해 효율성을 높이고, 수동 작업에 따른 오류 가능성을 줄일 수 있음
  • 주기적인 데이터 동기화, 대량의 데이터 마이그레이션, 정기적인 리포트 생성 등에 사용
  • 스케줄러
    • 주기적인 작업을 스케줄링하는 데 사용
    • 스케줄러를 사용하면 정해진 시간에 특정 작업을 실행할 수 있음
  • 배치
    • 대량의 데이터 처리 작업을 효율적으로 처리하기 위해 설계됨
    • 복잡한 데이터 처리 과정을 단순화하고 성능을 최적화할 수 있음

2. SAS 스케쥴러 개념

  • 스케쥴러 주요 기능
    • SAS는 서비스 또는 Java 구현체 호출을 통해 서비스들을 스케쥴링 하는 기능을 제공함
    • SAS 스케쥴러는 시작 시간이나 반복 횟수에 대한 제어를 제공함
  • 단일 서비스 스케쥴링 방법
    • App이 배포되어 있는 상태에서, 특정 노드에 지정된 서비스를 스케쥴링 함
    • RunSchedule 서비스를 호출함으로써 자신의 서비스를 스케쥴링 할 수 있음
    • 예시 코드
public class ScheduleServiceExample extends AbstractServiceObject {
    @Override
    public void service(BodyObject bodyObject) {
//Request DTO 준비
        RunScheduleInDto runScheduleInDto = new RunScheduleInDto();
        runScheduleInDto.setName("example-schedule");
        //현재 NID 획득 및 지정 (특정 Node 지정 가능)
        runScheduleInDto.setNodeId(Global.getInstance().getNID()));
        //현재 Application 명 획득
        runScheduleInDto.setTargetServiceName(Global.currentApplicationName.get() + "/com.example.exampleService");
        TaskObject taskObject = ServiceManager.getInstance()
                                     .callAsync("0/" + AdminService.Schedule.CallSchedule, runScheduleInDto, null, true);
//Response DTO 처리 (필요시)
        RunScheduleOutDto runScheduleOutDto = (RunScheduleOutDto) taskObject.getReply().getBody();
        runScheduleOutDto.getScheduleId();
        runScheduleOutDto.isResult();
        runScheduleOutDto.getResponseMessage();
    }
    @Override
    public void completeService() {}
}