개발을 시작하면서 ...
다양한 기능들을 개발하고 싶어서 인스타그램과 같은 소셜미디어 플랫폼을 개발하게 되었다.
정말 빠른 시간 안에 개발을 하게 되어서 지금 다시 코드를 보면 또 다른 생각이 떠오르는 상태이다.
이런 과정도 생각이 넓어지면서 성장하는 과정이라고 생각한다!
문제를 여러 관점에서 생각하다보면 머리가 복잡해질 때가 있다. (그래도 해야지~)
ERD를 설계하며
처음에 먼저 ERD를 구성하였고, 다양한 기능을 개발하게 되었다. 소셜 로그인도 구현하였고, 좋아요 기능, 어드민 로그 기능, 스케쥴러 설정 등 다양한 경험을 했다. (기능별로 게시물을 고민해 본 것들을 작성해 볼 예정이다.)
개발을 하면서 좀 바뀐 부분이 있었다. 특히 피드 좋아요 기능이었다. (ERD가 바뀌었다기보다 처음에 Redis를 도입할 생각을 하지 못했다.) 먼저 ERD 부터 보면서 말해보겠다.
밑에는 전체 프로젝트에서의 사용자, 피드, 좋아요 등 피드 관련 ERD의 일부분이다.


일단은 erd를 보면 좋아요 수 like_count는 좋아요 테이블에서 가져오기보다는, 피드에 좋아요 수를 볼 수 있도록 반정규화를 진행했다.
만약에 게시글 하나에 좋아요가 정말 많다면(백만개 이상) 게시글을 조회할 때마다 그 많은 개수의 좋아요를 조인하게 되며 성능이 매우 떨어질 것으로 예상했었다.
그래서 Sync가 매순간 맞지는 않더라도 반정규화를 해서 좋아요 수를 관리하는게 맞다고 판단하여 나누게 되었다.
고민 1. DB에 모든 사용자들의 좋아요 요청이 들어가게 된다면?
ERD를 설계하고 개발에 들어가게 되었는데 또 고민이 생겼다.
고민 : 피드별로 다수의 사용자들이 좋아요를 누르게 되는 일이 많을 텐데 그때마다 DB에 insert 또는 delete 하는 것은 매우 비효율적이다.
그래서 먼저 Redis에 특정 피드의 특정 유저의 좋아요 정보를 담아서 빠르게 조회하고 처리하는 것이 좋다고 판단하고 바로 Redis를 도입하게 되었다.
좋아요 로직을 보면, 한 사용자가 좋아요를 클릭한다면 좋아요 +1이 되고, 좋아요인 상태에서 다시 클릭한다면 다시 좋아요 -1이 된다.
그렇기 때문에 따로 API를 빼지 않고, 같은 API로 처리하였고 toggle 형태로 구현하였다.
toggle 형태로 동작하기 위해서는 사용자가 어떤 피드에 좋아요를 했는지 알아야 하기 때문에 아래와 같은 두 개의 키로 관리하였다.
private static final String FEED_LIKE_COUNT_KEY = "feed:like:count:%d";
private static final String FEED_LIKE_USERS_KEY = "feed:like:users:%d";
// 123 피드별 좋아요 개수 저장
"feed:like:count:123" → "15"
// 123 피드에 좋아요한 사용자들 (ZSet - 시간순 정렬)
"feed:like:users:123" → {user456: 1672531200000, user789: 1672531250000}
toggleLike가 실행되면,
먼저 사용자의 현재 좋아요 상태를 확인하고, (이 경우 때문에 FEED_LIKE_USERS_KEY를 사용한다.)
상태에 따라서 addLike 또는 removeLike를 진행한다.
// FeedLikeService
@Transactional
public LikeToggleResponse toggleLike(Long feedId, Long userId) {
if (!rateLimiterService.isFeedLikeAllowed(userId, feedId)) {
throw new CustomException(ErrorCode.TOO_MANY_REQUESTS);
}
if (!feedCacheService.feedExists(feedId)) {
throw new CustomException(ErrorCode.FEED_NOT_FOUND);
}
return redisLikeFacade.toggleLike(feedId, userId);
}
// RedisLikeFacade
public LikeToggleResponse toggleLike(Long feedId, Long userId) {
String likeCountKey = String.format(FEED_LIKE_COUNT_KEY, feedId);
String likeUsersKey = String.format(FEED_LIKE_USERS_KEY, feedId);
String userLikedKey = String.format(USER_LIKED_FEEDS_KEY, userId);
Double score = stringRedisTemplate.opsForZSet().score(likeUsersKey, userId.toString());
Boolean isLiked = (score != null);
if (Boolean.TRUE.equals(isLiked)) {
return removeLike(feedId, userId, likeCountKey, likeUsersKey, userLikedKey);
} else {
return addLike(feedId, userId, likeCountKey, likeUsersKey, userLikedKey);
}
}
/*
{
"liked": true,
"likeCount": 2,
"message": "좋아요를 눌렀습니다.",
"timestamp": "2025-09-19 21:51:24"
}
*/
응답을 받는 입장에서는 liked 값을 보고 UI를 표현하도록 설계하였다.
addLike에서는 아래 작업을 실행한다.
// 작업을 원자적으로 실행
operations.multi();
operations.opsForValue().increment(likeCountKey, 1); // 카운트 +1
operations.opsForZSet().add(likeUsersKey, userId, timestamp); // 피드→사용자 추가
return operations.exec();
removeLike에서는 아래 작업을 실행한다.
operations.multi();
operations.opsForValue().decrement(likeCountKey, 1); // 카운트 -1
operations.opsForZSet().remove(likeUsersKey, userId); // 피드→사용자 제거
return operations.exec();
이때, MULTI-EXEC를 사용해서 (operations.multi();) 작업이 모두 성공하거나 모두 실패하도록 처리하였다.
코드를 넣자면 아래와 같다.
private LikeToggleResponse addLike(Long feedId, Long userId, String likeCountKey,
String likeUsersKey, String userLikedKey) {
try {
long timestamp = System.currentTimeMillis();
List<Object> results = stringRedisTemplate.execute(new SessionCallback<List<Object>>() {
@Override
public List<Object> execute(RedisOperations operations) throws DataAccessException {
operations.multi();
operations.opsForValue().increment(likeCountKey, 1);
operations.opsForZSet().add(likeUsersKey, userId.toString(), timestamp);
return operations.exec();
}
});
if (results == null || results.isEmpty()) {
log.warn("좋아요 추가 실패 - 피드: {}, 사용자: {}", feedId, userId);
throw new CustomException(ErrorCode.LIKE_REDIS_TRANSACTION_FAILED);
}
addToSyncQueue(feedId, userId, "ADD");
Integer likeCount = getLikeCount(feedId);
log.info("좋아요 추가 완료 - 피드: {}, 사용자: {}, 현재 좋아요 수: {}", feedId, userId, likeCount);
return LikeToggleResponse.builder()
.liked(true)
.likeCount(likeCount)
.message("좋아요를 눌렀습니다.")
.timestamp(LocalDateTime.now())
.build();
} catch (Exception e) {
throw new CustomException(ErrorCode.INTERNAL_ERROR);
}
}
private LikeToggleResponse removeLike(Long feedId, Long userId, String likeCountKey,
String likeUsersKey, String userLikedKey) {
try {
List<Object> results = stringRedisTemplate.execute(new SessionCallback<List<Object>>() {
@Override
public List<Object> execute(RedisOperations operations) throws DataAccessException {
operations.multi();
operations.opsForValue().decrement(likeCountKey, 1);
operations.opsForZSet().remove(likeUsersKey, userId.toString());
return operations.exec();
}
});
if (results != null && !results.isEmpty()) {
addToSyncQueue(feedId, userId, "REMOVE");
} else {
throw new CustomException(ErrorCode.LIKE_REDIS_TRANSACTION_FAILED);
}
Integer likeCount = Math.max(0, getLikeCount(feedId));
log.info("좋아요 취소 완료 - 피드: {}, 사용자: {}, 현재 좋아요 수: {}", feedId, userId, likeCount);
return LikeToggleResponse.builder()
.liked(false)
.likeCount(likeCount)
.message("좋아요를 취소했습니다.")
.timestamp(LocalDateTime.now())
.build();
} catch (Exception e) {
throw new CustomException(ErrorCode.INTERNAL_ERROR);
}
}
redis에 좋아요 count, user, feed 정보를 저장하다 보니, 해당 정보가 DB에도 연동이 되어야 한다고 느껴졌다.
그래서 addToSyncQueue()를 사용하였다.
redis 데이터는 바로 수정되지만 MySQL에 업데이트하는 작업은 Redis에서 Queue로 관리하는 것처럼 비동기적으로 해당 action에 따라서 db를 수정하는 작업을 할 수 있도록 한 것이다.
스케쥴러를 사용해서 특정 주기마다 추가 또는 제거된 FeedLike 엔티티를 MySQL에 업데이트해주는 로직을 추가하였다.
@Component
@RequiredArgsConstructor
@Slf4j
public class LikeSyncScheduler {
private static final String LIKE_SYNC_QUEUE_KEY = "like:sync:queue";
private final RedisTemplate<String, Object> redisTemplate;
private final FeedLikeRepository feedLikeRepository;
private final FeedRepository feedRepository;
private final UserService userService;
@Scheduled(fixedDelay = 5000)
@Transactional
public void syncLikesToDatabase() {
try {
int batchSize = 100;
List<Object> syncItems = new ArrayList<>();
for (int i = 0; i < batchSize; i++) {
Object item = redisTemplate.opsForList().rightPop(LIKE_SYNC_QUEUE_KEY);
if (item == null) {
break;
}
syncItems.add(item);
}
if (syncItems.isEmpty()) {
return;
}
log.info("좋아요 DB 동기화 시작 - 처리할 항목: {}개", syncItems.size());
for (Object item : syncItems) {
try {
processSyncItem((Map<String, Object>) item);
} catch (Exception e) {
log.error("좋아요 동기화 실패: {}", item, e);
}
}
log.info("좋아요 DB 동기화 완료 - 처리된 항목: {}개", syncItems.size());
} catch (Exception e) {
log.error("좋아요 동기화 스케줄러 오류", e);
}
}
private void processSyncItem(Map<String, Object> item) {
Long feedId = Long.valueOf(item.get("feedId").toString());
Long userId = Long.valueOf(item.get("userId").toString());
String action = item.get("action").toString();
Feed feed = feedRepository.findById(feedId).orElse(null);
if (feed == null) {
log.warn("존재하지 않는 피드 ID: {}", feedId);
return;
}
User user = userService.findById(userId);
if ("ADD".equals(action)) {
if (!feedLikeRepository.existsByFeedIdAndUserId(feedId, userId)) {
FeedLike feedLike = FeedLike.create(feed, user);
feedLikeRepository.save(feedLike);
feed.increaseLikeCount();
feedRepository.save(feed);
}
} else if ("REMOVE".equals(action)) {
feedLikeRepository.deleteByFeedIdAndUserId(feedId, userId);
feed.decreaseLikeCount();
feedRepository.save(feed);
}
}
}
고민 2. Batch로 진행하자!
해당 코드를 짰을 때는 DB의 일관성을 지키기 위하여 행위가 일어날 때마다 db에서 모든 업데이트 작업이 이루어졌었다.
개별적인 수정이 들어갔었기에 매우 많은 트랜잭션이 일어나고 있었다.
1. 큐에서 100개씩 아이템 꺼내기
2. 각 아이템을 개별적으로 처리 (for loop)
3. 각 처리마다 Feed, User 엔티티 조회
4. FeedLike 개별 저장/삭제
5. Feed 엔티티의 like_count 개별 업데이트
다시 코드를 살펴보니 각 쿼리를 날리고 있다는 문제점을 깨닫고, 배치로 수행하도록 다시 수정 작업에 들어갔다.
@Component
@RequiredArgsConstructor
@Slf4j
public class LikeSyncScheduler {
private static final String LIKE_SYNC_QUEUE_KEY = "like:sync:queue";
private static final int BATCH_SIZE = 100;
private final RedisTemplate<String, Object> redisTemplate;
private final FeedLikeRepository feedLikeRepository;
@Scheduled(fixedDelay = 2000)
@Transactional
public void processSyncQueue() {
List<LikeAction> actions = new ArrayList<>();
for (int i = 0; i < BATCH_SIZE; i++) {
Map<String, Object> item = (Map<String, Object>)
redisTemplate.opsForList().rightPop(LIKE_SYNC_QUEUE_KEY);
if (item == null) {
break;
}
actions.add(new LikeAction(
Long.valueOf(item.get("feedId").toString()),
Long.valueOf(item.get("userId").toString()),
item.get("action").toString(),
Long.valueOf(item.get("timestamp").toString())
));
}
if (actions.isEmpty()) {
return;
}
Map<String, LikeAction> finalActions = new HashMap<>();
for (LikeAction action : actions) {
String key = action.feedId + "_" + action.userId;
LikeAction existing = finalActions.get(key);
if (existing == null || action.timestamp > existing.timestamp) {
finalActions.put(key, action);
}
}
log.info("좋아요 동기화: {} 개 액션 → {} 개 최종 액션", actions.size(), finalActions.size());
List<LikeAction> addActions = new ArrayList<>();
List<LikeAction> removeActions = new ArrayList<>();
for (LikeAction action : finalActions.values()) {
if ("ADD".equals(action.action)) {
addActions.add(action);
} else if ("REMOVE".equals(action.action)) {
removeActions.add(action);
}
}
if (!addActions.isEmpty()) {
processBatchAdd(addActions);
}
if (!removeActions.isEmpty()) {
processBatchRemove(removeActions);
}
}
private void processBatchAdd(List<LikeAction> addActions) {
List<Long> feedIds = addActions.stream().map(a -> a.feedId).collect(Collectors.toList());
List<Long> userIds = addActions.stream().map(a -> a.userId).collect(Collectors.toList());
List<LikePair> pairs = feedLikeRepository.findExistingPairs(feedIds, userIds);
Set<String> existingPairs = Optional.ofNullable(pairs)
.orElse(Collections.emptyList())
.stream()
.map(pair -> pair.getFeedId() + "_" + pair.getUserId())
.collect(Collectors.toSet());
List<FeedLike> newLikes = addActions.stream()
.filter(action -> !existingPairs.contains(action.feedId + "_" + action.userId))
.map(action -> FeedLike.create(action.feedId, action.userId))
.collect(Collectors.toList());
if (!newLikes.isEmpty()) {
try {
feedLikeRepository.saveAll(newLikes);
log.info("좋아요 {} 개 배치 추가", newLikes.size());
} catch (Exception e) {
log.error("배치 추가 실패", e);
}
}
}
private void processBatchRemove(List<LikeAction> removeActions) {
List<Long> feedIds = removeActions.stream().map(a -> a.feedId).collect(Collectors.toList());
List<Long> userIds = removeActions.stream().map(a -> a.userId).collect(Collectors.toList());
int deletedCount = feedLikeRepository.batchDeleteByFeedIdsAndUserIds(feedIds, userIds);
log.info("좋아요 {} 개 배치 삭제", deletedCount);
}
private static class LikeAction {
Long feedId;
Long userId;
String action;
Long timestamp;
LikeAction(Long feedId, Long userId, String action, Long timestamp) {
this.feedId = feedId;
this.userId = userId;
this.action = action;
this.timestamp = timestamp;
}
}
}
이 코드로 수정한 이유는 일단 배치 처리로 db 업데이트 동작 과정을 줄이는데 목적이 있었다. 또한, 스케쥴러가 돌기 전에 쌓인 데이터들에서 중복된 데이터들이 있을 수 있다.
같은 유저가 같은 피드에 대해서 좋아요 + 1, 좋아요 - 1, 좋아요 + 1, 좋아요 - 1, 좋아요 + 1을 했다면, 마지막 좋아요 + 1 만 하면 된다. 중복 데이터들은 없애고 마지막 동작만 하는 것이 효율적이라고 생각하였다.
이렇게 바꾸면서 수정한 부분은 Feed와 FeedLike의 연관관계이다.
FeedLike를 추가할 때, Feed와 User를 모두 다시 db에 검색한 후에 넣는 것이 비효율적이라고 생각하였다. 그래서 연관관계를 끊고, feedId, UserId를 기반으로 FeedLike에만 추가하도록 수정하였다. 이를 통해서 다수의 좋아요 ADD, REMOVE 요청들을 배치 처리할 수 있도록 개선해 보았다.
수정 후에 좋아요를 눌렀을 때 발생하는 결과를 비교해 보았다.
먼저 처음에는 JPA가 엔티티 객체 단위로 피드/유저를 로딩하고, like_count까지 갱신하였다. 그리고 이때 여러 요청이 스케쥴러에서 한 번에 처리된다면 각각마다 insert문이 실행되었을 것이다.
2025-09-20T01:08:08.910+09:00 INFO 53851 --- [nio-8080-exec-4] o.e.g.c.feed.FeedLikeController : 좋아요 토글 요청 - 피드: 3, 사용자: tester4
2025-09-20T01:08:08.916+09:00 INFO 53851 --- [nio-8080-exec-4] o.e.g.service.facade.RedisLikeFacade : 좋아요 추가 완료 - 피드: 3, 사용자: 8, 현재 좋아요 수: 1
Hibernate: insert into admin_logs (created_at,description,log_type,metadata,role,target_id,target_type,user_id) values (?,?,?,?,?,?,?,?)
2025-09-20T01:08:11.554+09:00 INFO 53851 --- [cheduled-task-1] o.e.g.controller.feed.LikeSyncScheduler : 좋아요 DB 동기화 시작 - 처리할 항목: 1개
Hibernate: select f1_0.id,f1_0.comment_count,f1_0.content,f1_0.created_at,f1_0.deleted_at,f1_0.deleted_by,f1_0.like_count,f1_0.report_count,f1_0.status,f1_0.updated_at,f1_0.user_id from feed f1_0 where f1_0.id=?
Hibernate: select u1_0.id,u1_0.birthdate,u1_0.created_at,u1_0.last_login_at,u1_0.name,u1_0.password,u1_0.phone,u1_0.profile_image_url,u1_0.provider,u1_0.provider_id,u1_0.role,u1_0.status,u1_0.subscription_status,u1_0.updated_at,u1_0.username from users u1_0 where u1_0.id=?
Hibernate: select fl1_0.id from feed_like fl1_0 left join feed f1_0 on f1_0.id=fl1_0.feed_id left join users u1_0 on u1_0.id=fl1_0.user_id where f1_0.id=? and u1_0.id=? limit ?
Hibernate: insert into feed_like (created_at,feed_id,user_id) values (?,?,?)
2025-09-20T01:08:11.560+09:00 INFO 53851 --- [cheduled-task-1] o.e.g.controller.feed.LikeSyncScheduler : 좋아요 DB 동기화 완료 - 처리된 항목: 1개
Hibernate: update feed set comment_count=?,content=?,created_at=?,deleted_at=?,deleted_by=?,like_count=?,report_count=?,status=?,updated_at=?,user_id=? where id=?
수정 후
좋아요 수를 count(*)로 조회하고, 중복이 있는지 확인하고, insert 하게 된다. 훨씬 간단한 방식으로 진행되는 것을 알 수 있다.
2025-09-20T01:18:55.359+09:00 INFO 83395 --- [nio-8080-exec-4] o.e.g.c.feed.FeedLikeController : 좋아요 토글 요청 - 피드: 3, 사용자: tester4
Hibernate: select count(*) from feed f1_0 where f1_0.id=?
2025-09-20T01:18:55.398+09:00 INFO 83395 --- [nio-8080-exec-4] o.e.g.service.facade.RedisLikeFacade : 좋아요 추가 완료 - 피드: 3, 사용자: 7, 현재 좋아요 수: 1
Hibernate: insert into admin_logs (created_at,description,log_type,metadata,role,target_id,target_type,user_id) values (?,?,?,?,?,?,?,?)
2025-09-20T01:18:56.711+09:00 INFO 83395 --- [cheduled-task-7] o.e.g.controller.feed.LikeSyncScheduler : 좋아요 동기화: 1 개 액션 → 1 개 최종 액션
Hibernate: select fl1_0.feed_id,fl1_0.user_id from feed_like fl1_0 where fl1_0.feed_id in (?) and fl1_0.user_id in (?)
Hibernate: insert into feed_like (created_at,feed_id,user_id) values (?,?,?)
2025-09-20T01:18:56.718+09:00 INFO 83395 --- [cheduled-task-7] o.e.g.controller.feed.LikeSyncScheduler : 좋아요 1 개 배치 추가
이때, 또 코드를 보면서 다양한 궁금증이 생겨났다.
- 정확성 및 DB 측면
이건 query를 짜면서 생각이 났는데, 위 코드에서 actions 리스트에서 feedId와 userId를 따로 리스트로 뺀 후에 MySQL에 쿼리로 insert, delete, select 하고 있는데 이게 과연 맞는가?라는 의문이 생겼다.
SELECT feed_id, user_id
FROM feed_like
WHERE feed_id IN (:feedIds)
AND user_id IN (:userIds)
이 쿼리는 feedId와 userId를 독립적으로 IN 조건에 넣기 때문에, 실제 존재하지 않는 (feedId, userId) 조합도 매칭될 수 있다.
예를 들어, 실제 DB에는 (feed=1, user=100), (feed=2, user=200)만 있음
요청으로 (feed=1, user=200)이 들어옴
그런데 위 방식에서는 feedIds=[1,2], userIds=[100,200] →(1,200) 도 매칭된 것처럼 코드 단에서는 feedIds=[1,2] / userIds=[100,200]를 모두 포함하고 있으니까, 마치 (1,200)도 이미 있다고 착각할 수 있는 로직이 만들어질 수 있다.
하지만 DB 자체에는 요청한 데이터가 없기 때문에 existing으로 반환되지 않을 것이기에 문제는 되지 않을 것 같다.
좀 코드가 찝찝해서 (feed_id, user_id) 튜플 단위로 비교하려고 했다.
그런데 MySQL 이슈가 있었다. MySQL은 (feed_id, user_id) IN ((1,2),(3,4)) 튜플 비교를 지원하지 않는다. (Postgres는 튜플 IN 을 지원한다고 한다..)
JDBC batch로 (feedId, userId) 페어를 직접 조회하는 방식으로 고쳐야 한다고 한다.
이 부분은 현재 코드에서 엄청난 이슈가 있을 것 같지 않아서 일시 정지해 두었다.
고민 3. Redis가 진정으로 필요한 이유, Redis 데이터가 날아간다면?
또 다른 궁금증으로는,
- Redis에 있는 데이터로 조회해서 좋아요 수를 처리하는 것이 정말 맞나?
- 만약 Redis 좋아요 수, 피드별 좋아요 유저 데이터가 날아간다면?
- Redis에서 action에 대한 것을 redis에 저장하고, 처리하고 있는데 Redis데이터가 날아가면 DB에 처리되지 않은 기록들은 어떻게 처리하지?
해당 궁금증을 해소하고자 youtube에서 아래 영상을 보게 되었다.
https://www.youtube.com/watch?v=RY_2gElt3SA
해당 영상을 보면서 내가 왜 Redis를 쓰고 DB에 정보를 업데이트하려고 했는지 완전히 이해가 됐다. 동시에 내 구현에 부족한 부분도 많이 느꼈다.
영상 내용을 정리해 보면 트위터 좋아요나 유튜브 조회수처럼 수백만 건 요청이 동시에 들어오면 단일 DB에서 동기적으로 처리하는 건 사실상 불가능하다는 거였다. 동시 요청이 들어오면 레이스 컨디션 때문에 중복 반영이나 누락이 생기고, 캐시와 DB 사이의 시차 때문에 순간적인 불일치가 발생한다. 그래서 "지금 당장 정확한 숫자"보다는 "시간이 지나면 결국 맞춰지는 숫자"라는 결과적 일관성 개념을 사용하는 것이다.
결과적 일관성이라는 건 데이터가 여러 노드나 서버에 분산되어 있을 때 즉시 모든 곳이 똑같을 필요는 없지만, 시간이 지나면 결국 동일한 상태로 수렴한다는 보장을 의미한다. 예로, 트위터 좋아요 수를 보면 어떤 기기에서는 101개, 다른 기기에서는 98개로 보일 수 있지만 몇 초 뒤에는 같은 값이 된다. 해당 개념을 사용하는 이유는, 모든 요청을 즉시 동기화하면 속도가 너무 느려지고 병목이 생기기 때문이다.
이런 결과적 일관성을 위해 Redis 같은 캐시 서버를 앞단에 두고 쓸 수 있다. Redis의 역할은 자주 읽히는 데이터를 DB 대신 캐시에서 읽도록 해서 속도와 부하를 줄이는 것이다. 영상에서 보니까 캐시가 여러 개 있으면 업데이트 지연으로 숫자가 다르게 보일 수 있지만, DB에 부하를 주지 않아서 빠른 응답이 가능하기 때문에 대규모 서비스에서는 필수적으로 사용한다고 한다.
나는 지금 Redis를 1대만 사용하고 있어서 데이터가 날아가면 어떡하지 하는 고민에 빠져있었다. 하지만 대규모 서비스에서는 Redis Cluster를 사용해서 데이터를 여러 노드에 분산 저장하기 때문에 고성능과 확장성을 보장한다고 한다. 분산 처리가 되면 하나의 서버가 망가져도 시스템 전체에 큰 오류가 발생하지 않을 것 같다.
그런데 내가 걱정했던 부분은 여전히 남아있다. 지금 내 프로젝트에서는 1대의 Redis 서버에서 처리하고 있기 때문에 서버가 망가지면 사용자들의 행위 데이터를 모두 잃게 되어 큰 타격을 입을 것 같다.
이 과정에서 Message Queue가 생각났다. DB 요청을 안정적으로 처리하기 위해서 DB에 바로 요청하지 않고 Redis를 통해서 처리했지만, 결국에는 사용자 요청 정보들이 누락되지 않고 DB에 요청이 되어야 최종 일관성을 갖출 수 있다.
여기서 결국 동기화가 되기 위해서는 Message Queue의 역할이 필요하다고 생각한다. Message Queue를 사용하면 Redis가 장애가 나더라도 사용자의 행위 데이터를 안전하게 보관하고 DB에 동기화할 수 있을 것 같다. 지금 내가 Redis List로 큐를 구현한 것보다는 훨씬 더 안정적이고 신뢰할 수 있는 방법이 될 것 같다.
회고
결론적으로는 좋아요 처럼 매우 빠르게 변화하고, 실시간 정확도가 매우 정확하지 않아도 되는 경우에는 eventual consistency라는 개념을 사용하여 개발해야 한다는 것을 체감하게 되었다.
또한, Redis를 캐시서버로 사용했을 때의 이점을 깨달았다. DB에 직접 접근하지 않고 메모리 기반의 Redis에서 읽기/쓰기를 처리하면 응답 속도가 월등히 빨라지고, DB의 부하도 크게 줄일 수 있다. 특히 좋아요 같은 빈번한 업데이트가 발생하는 기능에서는 Redis의 원자적 연산과 데이터 구조를 활용하면 동시성 문제도 어느 정도 해결할 수 있다는 점이 인상적이었다. 다만 캐시와 DB 간의 데이터 동기화 문제는 여전히 고민이 필요한 부분이다.
마지막으로 아직 적용해보지는 못했지만, 메시지 큐(RabbitMQ, Kafka 등)의 필요성도 알게 되었다. 현재 Redis List로 구현한 동기화 큐는 Redis 장애 시 데이터 손실 위험이 있는데, 전용 메시지 큐를 사용하면 메시지 영속성, 재시도 로직, 배치 처리 등을 통해 더 안정적인 데이터 동기화가 가능할 것 같다. 또한 시스템이 복잡해질수록 각 컴포넌트 간의 결합도를 낮추고 확장성을 높이는 데도 메시지 큐가 중요한 역할을 할 것 같다. 이번 좋아요 시스템 구현을 통해 대규모 서비스에서 사용되는 아키텍처 패턴들을 고민해 볼 수 있어서 정말 좋았다! 뿌듯하다!
'Project' 카테고리의 다른 글
| OAuth 2.0을 활용한 로그인 구현을 하면서 (OAuth 2.0 동작, 인증/인가, Redis blackList...) (0) | 2025.10.03 |
|---|---|
| 좋아요 기능을 개발하면서 고민한 내용 v2 (Test와 대규모 트래픽 환경에서의 고민...) (1) | 2025.10.03 |
| 📌 ERD 설계 가이드 (+ 직접 겪은 ERD 설계 경험) (0) | 2025.01.29 |
| [LMS] 10일 동안의 '학습 관리 시스템' 개발 그리고 다른 팀과의 협업 이야기(1) (0) | 2025.01.27 |
| [inter-face]프로젝트 기획 마무리 회고록 (1) (1) | 2024.12.03 |
