게시글 조회수 기능 구현
단순 조회수 증가 기능
요구사항
게시글을 조회할 때마다 조회수가 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만 증가함.

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 객체를 생성하고, 필요한 스케줄러 정보를 설정함
- 로그 기록 : 스케줄러 정보와 등록 완료 메시지를 로그에 기록함
- 스케쥴러 동작 방식 : 스케줄러 서비스에 스케줄을 등록하고, 지정된 간격에 따라 서비스를 트리거
- 스케줄러 서비스는 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() {}
}