| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- @RequestMapping
- 정적 팩터리 메서드
- oauth2.0
- assert
- injellij
- ngrinder
- spring-cloud-starter-aws
- redis
- JPA
- fetch join
- websocket
- awspring
- mockito
- intellij
- batch insert
- spring
- MySQLTransactionRollbackException
- Cannotacquirelockexception
- convertAndSendToUser
- N + 1
- Commit
- Git
- @Transaction(readOnly=true)
- naturalid
- AWS
- 컨트리뷰터
- @controller
- OIDC
- 이펙티브 자바
- 프로젝트 이름 변경
- Today
- Total
정리정리
게시글 좋아요 기능 개선기2 (캐시, 배치 작업) 본문
2023.06.13 - [개발 기록] - 게시글 좋아요 기능 개선기 (동시성, 데드락)
이전 글에서는 게시글에 좋아요를 누를 때 발생하는 동시성 문제와 데드락에 대해 해결하고 개선을 해봤습니다.
이번에는 설계적인 측면에서 성능을 높일 수 있는 방법이 없을까 알아보던 중에 배치 작업에 대해 공부하게 되었습니다.
그래서 이번 포스팅에서는 좋아요 요청이 올 때마다 db에 insert를 하는 방식이 아닌, 캐시를 이용하여 여러 요청을 모아 한번에 배치 작업으로 db에 추가하는 작업을 기록해보려고 합니다.
캐시 전략
우선 배치 작업을 하기 위해 캐시 쓰기 전략인 Write Back 전략을 사용했습니다.
Write Back 전략은 데이터를 쓸 때 캐시에만 데이터를 쓰고 일정 주기로 배치 작업을 통해 db에 데이터를 저장하는 캐시 전략입니다.
이 방식을 사용한 이유를 설명하기 위해서는 우선 '좋아요'라는 기능의 특징에 대해 생각을 해봐야 합니다.
제가 구현하고 있는 인스타같은 sns에서는 만약 인기가 많은 사람일 경우 게시글을 하나 올리게 되면 많은 사람들이 순식간에 글에 좋아요를 누르는 상황이 많을 것이라고 생각했습니다.
즉, 단기간에 db에 많은 insert 쿼리가 작성되면서 과부하가 발생한다는 것을 의미합니다.
그렇기 때문에 순식간에 몰리는 트래픽을 캐시를 이용하여 앞에서 처리하고, 스케줄링을 이용해 배치로 db에 반영하여 db의 과부하를 줄이는 Write Back 전략이 적합하다고 생각했습니다.
이를 그림으로 표현하면 다음처럼 됩니다.

우선 좋아요 요청이 오면 캐시에서 좋아요 중복 여부를 확인합니다.
만약 중복이 없다면 db에서 한 번 더 확인을 하고 중복이 있을 경우 400 예외를 응답합니다.
중복이 없다면 캐시에 좋아요 정보를 저장합니다.
설계 변경
기존에는 DB의 게시글 테이블에 '좋아요 수'라는 칼럼이 존재했습니다.
하지만 캐시를 적용하면서 해당 칼럼을 제거하기로 했습니다.
원래는 설계 변경 없이 캐시를 적용하려고 했는데, 데이터의 정합성 문제와 배치 작업에서의 문제 등이 발생했습니다.
우선 게시글을 조회할 때 해당 게시글의 좋아요 수를 조회해야 하는데 캐시를 적용하면 DB와 캐시를 모두 조회해야 하는 문제가 발생합니다.
또 배치 작업을 할 때 게시글마다 좋아요 수를 갱신하는 쿼리를 날려야 하기 때문에 캐시에 있는 서로 다른 게시글의 수만큼 쿼리가 생기게 되고, 이는 배치 작업을 할 때마다 DB에 과부하를 줄 수도 있다고 생각을 했습니다.
물론 해결을 할 수 있는 방법들이 있지만 좋아요를 취소할 경우도 생각을 하면서 구현을 해보니 생각보다 기존 코드에서 변경이 너무 많이 생겨 DB 테이블을 바꾸는 방식으로 변경을 했습니다.
다만 어떤 방법이 정답인지는 아직도 모르고 그저 제 경험을 공유하려는 것임을 유의해주시면 감사하겠습니다.
구현
1. 캐시
@Service
@Transactional
@RequiredArgsConstructor
public class LikeService {
private final LikeRepository likeRepository;
public void like(Long memberId, Long postId) {
validateAlreadyLikedPost(memberId, postId);
likeRepository.save(new Like(memberId, postId));
}
private void validateAlreadyLikedPost(Long memberId, Long postId) {
if (likeRepository.existsByMemberIdAndPostId(memberId, postId)) {
throw new AlreadyLikedPostException(postId, memberId);
}
}
}
우선 LikeService는 좋아요 수를 늘리는 부분을 제거한 것 외에는 크게 바뀐 점이 없습니다.
service 계층에서 스프링에서 제공하는 캐시 관련 어노테이션만 추가하는 것으로 해결을 하려고 했지만 해당 어노테이션으로 해결할 수 없다고 판단해서 따로 repository를 수정하기로 했습니다.
@Repository
public interface LikeRepository {
boolean existsByMemberIdAndPostId(Long memberId, Long postId);
void removeByMemberIdAndPostId(Long memberId, Long postId);
void save(Like like);
int countByPostId(Long postId);
}
@Repository
@RequiredArgsConstructor
public class LikeRepositoryImpl implements LikeRepository {
private final LikeJpaRepository likeJpaRepository;
private final RedisTemplate<String, Object> redisTemplate;
private final RedisService redisService;
@Override
public void save(Like like) {
String key = redisService.makeKey(RedisPrefix.LIKE_PUSH, like.getMemberId(), like.getPostId());
redisTemplate.opsForValue().set(key, LocalDateTime.now().toString(), LIKE_EXPIRED_SECONDS, TimeUnit.SECONDS);
}
@Override
public boolean existsByMemberIdAndPostId(Long memberId, Long postId) {
return isExistsInCache(memberId, postId) || isExistsInDB(memberId, postId);
}
private boolean isExistsInCache(Long memberId, Long postId) {
String key = redisService.makeKey(RedisPrefix.LIKE_PUSH, memberId, postId);
return Optional.ofNullable(redisTemplate.opsForValue().get(key))
.isPresent();
}
private boolean isExistsInDB(Long memberId, Long postId) {
return likeJpaRepository.existsByMemberIdAndPostId(memberId, postId);
}
@Override
public void removeByMemberIdAndPostId(Long memberId, Long postId) {
String key = redisService.makeKey(RedisPrefix.LIKE_PUSH, memberId, postId);
Boolean cacheDelete = redisTemplate.delete(key);
if (isCacheDeleted(cacheDelete)) {
likeJpaRepository.deleteByMemberIdAndPostId(memberId, postId);
}
}
private boolean isCacheDeleted(Boolean cacheDelete) {
return Boolean.FALSE.equals(cacheDelete);
}
@Override
public int countByPostId(Long postId) {
return likeJpaRepository.countByPostId(postId);
}
}
LikeRepositoryImpl에서는 JpaRepository를 상속받은 repository와 redisTemplate를 주입받아 처리를 해줍니다.
각 메서드들을 살펴보면,
- save: key - LikePushed::멤버아이디:게시글아이디 value - 좋아요 누른 시간 으로 캐시에 저장을 합니다.
ex) LikePushed::1:1 - existsByMemberIdAndPostId: 캐시와 DB에서 좋아요를 눌렀는지 중복 체크를 합니다.
- removeByMemberIdAndPostId: 캐시에서 데이터를 지우고, 만약 실패하면 db에서 데이터를 삭제합니다.
- countByPostId: 게시글의 좋아요 수를 가져옵니다.
@Service
@RequiredArgsConstructor
public class RedisService {
private static final String KEY_FORMAT = ":%s";
private final RedisTemplate<String, Object> redisTemplate;
public String makeKey(RedisPrefix prefix, Object ...args) {
StringBuilder key = new StringBuilder(prefix.getPrefix());
for (Object arg : args) {
key.append(String.format(KEY_FORMAT, arg));
}
return key.toString();
}
...
}
키를 만드는데 쓰이는 메서드인 makeKey는 레디스 관련 컴포넌트에서 처리를 하도록 했습니다.
단순히 좋아요 뿐만이 아닌 여러 키를 만들 수 있도록 prefix 뒤에 붙을 값들을 가변인자로 받아 처리를 합니다.
2. 배치 작업 스케줄링
@Slf4j
@Component
@RequiredArgsConstructor
public class LikeBatchJobScheduler {
private final LikeBatchJobService likeBatchJobService;
@Scheduled(fixedDelay = 1000 * 60 * 2)
public void execute() {
LocalDateTime start = LocalDateTime.now();
log.info("배치 스케줄링 시작 Time: {}", start);
likeBatchJobService.updateRDB();
LocalDateTime end = LocalDateTime.now();
log.info("배치 스케줄링 종료 Time: {}, elapsed: {}", end, Duration.between(start, end));
}
}
스케줄링은 스프링에서 제공하는 스케줄링을 이용하기로 했습니다.
딜레이 간격은 2분으로 설정을 해줬습니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class LikeBatchJobService {
private static final int PARAMETER_OFFSET = 1;
private static final int MEMBER_ID_INDEX = 2;
private static final int POST_ID_INDEX = 3;
private final JdbcTemplate jdbcTemplate;
private final RedisService redisService;
private final RedisTemplate<String, Object> redisTemplate;
public void updateRDB() {
List<String> keys = redisService.scanKeys(RedisPrefix.LIKE_PUSH);
log.info("{}개 배치 작업 진행", keys.size());
doBatchInsert(keys);
}
private void doBatchInsert(List<String> keys) {
String sql = "INSERT IGNORE INTO likes (member_id, post_id, created_at, last_modified_at) values (?, ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
String key = keys.get(i);
Timestamp createdAt = getCreatedAt(key);
String[] memberIdAndPostId = key.split(":");
ps.setLong(PARAMETER_OFFSET, Long.parseLong(memberIdAndPostId[MEMBER_ID_INDEX]));
ps.setLong(PARAMETER_OFFSET + 1, Long.parseLong(memberIdAndPostId[POST_ID_INDEX]));
ps.setTimestamp(PARAMETER_OFFSET + 2, createdAt);
ps.setTimestamp(PARAMETER_OFFSET + 3, createdAt);
}
@Override
public int getBatchSize() {
return keys.size();
}
});
}
private Timestamp getCreatedAt(String key) {
String createdAt = (String) redisTemplate.opsForValue().get(key);
return Timestamp.valueOf(toLocalDateTime(createdAt));
}
private LocalDateTime toLocalDateTime(String value) {
return Optional.ofNullable(value)
.map(LocalDateTime::parse)
.orElseGet(LocalDateTime::now);
}
}
여기서 Jpa를 사용하지 않고 jdbc를 사용한 이유는 저의 설계에서는 Jpa(정확히는 hibernate)의 batch insert가 불가능하기 때문입니다.
write behind 캐싱 전략을 사용하는 hibernate는 영속성 컨텍스트 내부에서 entity들을 타입과 id 값을 통해 구분을 하는데, id 값을 생성하는 전략 중 하나인 Identity를 사용하면 DB에 insert를 해야 id 값을 확인 가능하기 때문에 batch insert를 disable 시키기 때문에 Jpa를 사용할 수 없었습니다.
Hibernate disables insert batching at the JDBC level transparently if you use an identity identifier generator. (docs)
Whenever an entity is persisted, Hibernate must attach it to the currently running Persistence Context which acts as a Map of entities. The Map key is formed of the entity type (its Java Class) and the entity identifier.
For IDENTITY columns, the only way to know the identifier value is to execute the SQL INSERT. Hence, the INSERT is executed when the persist method is called and cannot be disabled until flush time.
For this reason, Hibernate disables JDBC batch inserts for entities using the IDENTITY generator strategy. (원본)
여기에서 또 눈여겨볼 점은 쿼리에 예외가 발생했을 때 무시하는 ignore 키워드를 추가한 점입니다.
처음에 데이터를 캐싱할 때 가장 고민을 많이 했던 부분이 데이터의 만료 시간이었습니다.
데이터의 만료 시간에 따라 아래와 같이 두 가지 상황이 발생합니다.
1. 만료 시간 <= 스케줄링 간격
만료 시간이 스케줄링 간격보다 짧을 경우 스케줄링이 이뤄지기 전에 데이터가 만료되어 데이터 손실이 발생하게 됩니다.
만료 시간과 스케줄링 간격이 같을 경우에도 실제로 스케줄링을 하기 위해 캐시를 읽는 시간이 소요되기 때문에 데이터 유실이 발생할 수 있습니다.

2. 만료 시간 > 스케줄링 간격
만료 시간이 스케줄링 간격보다 길 경우 스케줄링 하기 얼마 전에 캐시에 등록된 데이터는 잘못하면 다음 스케줄링에도 한 번 더 db에 저장이 될 수 있다는 문제점이 있습니다.
이를 그림으로 표현하면 다음과 같습니다.

그래서 이를 어떻게 해결할지 생각해보다가 좋아요 테이블의 member_id와 post_id에 유니크 제약 조건을 걸고 쿼리에 ignore을 추가한 후, 만료 시간을 스케줄링 간격보다 길게 잡는 방식을 선택했습니다.
이렇게 되면 중복된 데이터가 다음 스케줄링에 들어와도 유니크 제약 조건에 걸려 예외가 발생하지만, ignore 키워드를 통해 실제로는 예외 없이 배치 작업이 끝나게 됩니다.
rewriteBatchedStatements=true
또 삽질할 뻔한 부분인데 MySQL의 batch insert를 위해 datasource url에 해당 옵션을 추가해야 배치 작업이 되기 때문에 추가해줬습니다.
@Service
@RequiredArgsConstructor
public class RedisService {
private static final int SCAN_COUNT = 10;
private static final String ALL = "*";
private final RedisTemplate<String, Object> redisTemplate;
public List<String> scanKeys(RedisPrefix prefix) {
String pattern = makeKey(prefix, ALL);
RedisConnection connection = getRedisConnection();
ScanOptions scanOptions = getScanOptions(pattern);
Cursor<byte[]> cursor = connection.scan(scanOptions);
return getKeys(cursor);
}
private RedisConnection getRedisConnection() {
RedisConnectionFactory connectionFactory = redisTemplate.getConnectionFactory();
assert connectionFactory != null;
return connectionFactory.getConnection();
}
private ScanOptions getScanOptions(String pattern) {
return ScanOptions
.scanOptions()
.match(pattern)
.count(SCAN_COUNT)
.build();
}
private List<String> getKeys(Cursor<byte[]> cursor) {
List<String> keys = new ArrayList<>();
while (cursor.hasNext()) {
keys.add(new String(cursor.next()));
}
return keys;
}
}
마지막으로 redis 조회를 위한 코드입니다.
LikePushed라는 prefix가 붙은 key들을 모두 조회합니다.
성능 비교
nGrinder를 이용하여 성능 테스트를 해봤고, 아래와 같은 결과를 얻었습니다.


| 변경 전 (캐시 적용 x) | 변경 후 (캐시 적용) | |
| TPS | 62.1 | 169.1 |
| Peak TPS | 76.0 | 204.0 |
| MTT(ms) | 231.01 | 90.54 |
캐시 적용 전과 비교를 해보면 차이를 크게 느낄 수 있었습니다.
물론 캐시 적용 전은 좋아요 수를 늘리는 로직이 추가되어 있기 때문에 차이가 더 크게 발생했을 것이고, 게시글 테이블에 좋아요 수에 대한 칼럼을 제거했기 때문에 게시글 조회 시 좋아요 수를 확인하는 쿼리로 인한 성능 저하도 생각을 해봐야 합니다.
다만 어느 쪽이 더 많은 트래픽이 몰릴지는 실제 서비스를 하지 못했기 때문에 확인을 할 수가 없었고, 상황에 맞춰서 트레이드오프를 해야 하는 부분이 아닌가 하는 생각이 들었습니다.
'개발 기록' 카테고리의 다른 글
| 게시글 좋아요 기능 개선기 (동시성, 데드락) (0) | 2023.06.13 |
|---|