Notice
Recent Posts
Recent Comments
Link
«   2025/06   »
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
Tags
more
Archives
Today
Total
관리 메뉴

To Dare Is To Do!

좋아요 기능, 어떻게 설계해야 할까? 본문

프로젝트

좋아요 기능, 어떻게 설계해야 할까?

Nick_Choi 2024. 7. 29. 09:53

좋아요 기능, 단순해 보여도 깊다

‘좋아요’는 단순히 버튼 하나를 누르는 작고 직관적인 기능처럼 보이지만 이 짧은 상호작용 뒤에는 수많은 기술적 고민이 숨어 있습니다.
데이터 정합성을 유지하며 좋아요 수를 빠르게 계산하고, 급격한 트래픽 증가와 다수 사용자의 동시 요청을 견디기 위해서는 조회 성능, 동시성 제어, 데이터 처리 방식에 대한 깊은 이해가 필요합니다.

특히 오늘날 좋아요 수는 단순한 호감 표현을 넘어, 콘텐츠 노출, 상품 추천, 사용자 행동 분석 등 핵심 로직의 기준으로 작동합니다.
즉, 좋아요는 이제 단순한 UI 기능이 아니라 비즈니스 흐름을 결정짓는 데이터의 시작점입니다.

이 글은 제가 SNS 프로젝트에서 좋아요 기능을 설계하고 개선해온 과정을 정리한 기술 회고입니다.
처음엔 단순한 INSERT-COUNT 구조에서 출발했지만, 실제 트래픽 상황에서 마주한 데드락, 재시도, 정합성 vs 성능 트레이드오프를 통해 결국 이 기능이 "락을 다루는 트랜잭션"이 아닌 "큐로 처리해야 할 이벤트"라는 본질을 깨닫게 되었습니다.

1. JOIN + COUNT 기반의 좋아요 구현

– 단순하지만 금방 한계에 부딪힌 구조

좋아요 기능은 크게 조회삽입 두 가지 작업으로 나뉜다.
초기 구현 단계에서는 정규화를 유지하고 빠르게 개발할 수 있는 구조를 선택했다. 게시글(Post) 테이블과 별도로 좋아요(Likes) 테이블을 분리하고, 게시글을 조회할 때 두 테이블을 JOIN하여 해당 게시글에 연결된 좋아요 수를 COUNT하는 방식이었다.

이 방식은 데이터 정규화를 지키면서도 삽입 작업이 단순한 INSERT로 이루어져 동시성 문제 없이 안정적으로 처리된다는 장점이 있다.
 그러나 서비스의 특성상, 사용자가 한 번에 여러 게시글을 조회하게 되면서 문제가 발생했다. 각 게시글마다 JOIN과 COUNT 연산이 수행되어야 했고, 좋아요 수가 많아질수록 전체 조회 쿼리의 복잡도와 부하가 급격히 증가할 수 있었다.

즉, 초기에 안정성과 정규화를 고려한 구조였지만, 게시글 수와 좋아요 수가 늘어날수록 성능 저하가 예상되는 방식이었다.

  • 구현 구조: Post 테이블과 Like 테이블 분리
  • 조회 로직: JOIN + COUNT 쿼리
  • 장점: 정규화 구조, 구현이 간단함
  • 한계:
    • 매 요청마다 count 연산 → 조회 성능 저하
    • 게시글 수 증가, 동시 조회 증가 시 병목 발생

정리:
초기 구조는 단순하고 안정적이었지만, 트래픽이 몰리는 환경에선 조회 성능의 병목이 명확해 보인다.

2. Post 테이블에 like_count 컬럼 추가

– 조회는 빨라졌지만, 동시성이라는 벽을 마주했다

1단계에서는 정규화를 유지하며 JOIN + COUNT 방식으로 좋아요 수를 계산했기 때문에, 구현이 간단하고 구조도 명확했다.

하지만 매번 LIKE 테이블을 JOIN하여 좋아요 수를 COUNT해야 했기 때문에 조회 요청이 많아질수록 성능 저하가 발생할 수밖에 없었다. 특히, 한 번에 다수의 게시글을 조회하는 피드 방식의 SNS 구조에서는 이 연산이 N배로 반복되면서 병목이 더 심화될 것으로 판단되었다.

 

반정규화 도입

이에 따라, 조회 성능을 개선하기 위해 Post 테이블에 like_count 컬럼을 직접 추가하는 방식으로 전환했다.
즉, 좋아요가 눌릴 때마다 해당 게시글의 like_count를 +1 해주는 반정규화 방식이다.

이 방식은 조회 시 단일 SELECT로 좋아요 수를 즉시 확인할 수 있어
JOIN 없이 빠른 응답이 가능해졌고, 피드 구조에도 훨씬 적합한 방식이었지만 또다른 문제가 발생했다.

 

동시성 이슈

다음은 여러 유저가 동시에 같은 게시글에 좋아요를 눌렀을 때, 최종적으로 likeCount가 정확히 증가했는지를 확인하는 테스트이다.

이 때, like_count 컬럼 업데이트 시 동시성 이슈가 발생했다.

@Test
    void 동시에_여러명이_좋아요를_누를때_정합성_확인() throws InterruptedException {        
        UserEntity user = userRepository.save(UserEntity.of("concurrentUser", "pw"));
        PostEntity post = postRepository.save(PostEntity.of("title", "body", user));

        int threadCount = 5;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            int finalI = i;
            executor.submit(() -> {
                try {
                    String userName = "user" + finalI;
                    userRepository.save(UserEntity.of(userName, "pw")); // 각각 다른 유저가 좋아요
                    postService.like(post.getId(), userName);
                } catch (Exception e) {
                    System.out.println("❌ 예외 발생: " + e.getClass().getSimpleName() + " - " + e.getMessage());

                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

        PostEntity result = postRepository.findById(post.getId()).orElseThrow();
        System.out.println("최종 좋아요 수: " + result.getLikeCount());
    }
}

테스트 결과는 다음의 예외와 최종 좋아요 수 1개였다.

❌ 예외 발생: CannotAcquireLockException - could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [/* update for sns.snsproject.model.entity.PostEntity */update `post` set body=?,deleted_at=?,like_count=?,registered_at=?,title=?,updated_at=?,user_id=? where id=?]; SQL [/* update for sns.snsproject.model.entity.PostEntity */update `post` set body=?,deleted_at=?,like_count=?,registered_at=?,title=?,updated_at=?,user_id=? where id=?]
❌ 예외 발생: CannotAcquireLockException - could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [/* update for sns.snsproject.model.entity.PostEntity */update `post` set body=?,deleted_at=?,like_count=?,registered_at=?,title=?,updated_at=?,user_id=? where id=?]; SQL [/* update for sns.snsproject.model.entity.PostEntity */update `post` set body=?,deleted_at=?,like_count=?,registered_at=?,title=?,updated_at=?,user_id=? where id=?]
❌ 예외 발생: CannotAcquireLockException - could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [/* update for sns.snsproject.model.entity.PostEntity */update `post` set body=?,deleted_at=?,like_count=?,registered_at=?,title=?,updated_at=?,user_id=? where id=?]; SQL [/* update for sns.snsproject.model.entity.PostEntity */update `post` set body=?,deleted_at=?,like_count=?,registered_at=?,title=?,updated_at=?,user_id=? where id=?]
❌ 예외 발생: CannotAcquireLockException - could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [/* update for sns.snsproject.model.entity.PostEntity */update `post` set body=?,deleted_at=?,like_count=?,registered_at=?,title=?,updated_at=?,user_id=? where id=?]; SQL [/* update for sns.snsproject.model.entity.PostEntity */update `post` set body=?,deleted_at=?,like_count=?,registered_at=?,title=?,updated_at=?,user_id=? where id=?]

 

원인 분석

에러 메시지를 보면 Deadlock found when trying to get lock이라는 문구가 반복된다.
즉, 서로 다른 트랜잭션이 동일한 자원에 대해 락을 획득하려다 충돌한 상황이다.

하지만 나는 명시적으로 @Lock, FOR UPDATE, synchronized, Redisson 등의 락을 사용한 적이 없다.
그렇다면 왜 락이 걸렸으며, 데드락까지 발생한 것일까?

SHOW ENGINE INNODB STATUS 명령어를 통해 InnoDB 내부 상태를 확인한 결과, 다음과 같은 데드락 상황이 감지되었다.

*** (1) TRANSACTION:
TRANSACTION 351891, ACTIVE 1 sec starting index read
LOCK WAIT ... lock mode S locks rec but not gap
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
... lock_mode X locks rec but not gap waiting

*** (2) TRANSACTION:
TRANSACTION 351893, ACTIVE 1 sec starting index read
LOCK WAIT ... lock mode S locks rec but not gap
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
... lock_mode X locks rec but not gap waiting

*** WE ROLL BACK TRANSACTION (2)

두 트랜잭션은 모두 post 테이블의 id=1인 동일한 레코드에 대해 접근하고 있었고,
초기에는 읽기 작업으로 인해 S Lock (Shared Lock)을 획득한 상태였다.
그러나 이후 좋아요 수 증가를 위한 UPDATE가 수행되면서 각각 X Lock (Exclusive Lock)으로의 승격을 시도했고, 이 과정에서 서로의 락이 해제되기를 기다리는 교착 상태(Deadlock)가 발생했다.

결국 InnoDB는 자동으로 트랜잭션 중 하나를 강제로 롤백시켜 데드락을 해소했으며, 이로 인해 애플리케이션에서는 CannotAcquireLockException 예외가 발생했다.
이는 명시적인 락 설정 없이도, InnoDB의 트랜잭션 처리 특성에 따라 UPDATE 같은 데이터 변경 쿼리만으로도 락 충돌과 데드락이 충분히 발생할 수 있다는 사실을 보여준다.

 

S-Lock(Shared Lock, 공유 잠금)이란?

더보기
  • 읽기(Read) 작업을 위해 사용됨
  • 여러 트랜잭션이 동시에 공유 잠금을 획득할 수 있음
  • 하지만 쓰기(Write) 작업은 불가능함

예시:

  • 트랜잭션 A가 어떤 데이터를 읽기 위해 S-Lock을 건 상태에서
    트랜잭션 B도 같은 데이터를 읽기 위해 S-Lock을 걸 수 있음 (공존 가능)
  • 그러나 트랜잭션 C가 해당 데이터를 수정하려고 하면 X-Lock을 걸 수 없으므로 대기 상태가 됨

X-Lock (Exclusive Lock, 배타 잠금)이란?

더보기
  • 쓰기(Write) 작업을 위해 사용됨
  • 한 트랜잭션만 해당 데이터에 대해 배타적으로 접근할 수 있음
  • 다른 어떤 잠금(S든 X든)도 동시에 걸릴 수 없음

예시:

  • 트랜잭션 A가 어떤 데이터를 수정하기 위해 X-Lock을 건 상태면,
    트랜잭션 B는 읽기(S-Lock)조차 할 수 없고, 쓰기도 당연히 못 함 (완전 독점)

낙관적 락의 도입

이처럼 명시적인 락을 사용하지 않아도 InnoDB 내부에서 자동으로 잠금이 발생하고, 결국 데드락으로 이어지는 상황을 확인할 수 있었다. 특히 좋아요처럼 여러 사용자가 동일한 게시글을 대상으로 동시에 업데이트를 시도하는 경우, 하나의 레코드를 중심으로 락 충돌이 집중되기 때문에 이런 현상이 빈번하게 발생할 수 있다.

이 문제를 해결하기 위해, 나는 낙관적 락(Optimistic Lock)을 도입했다.

낙관적 락은 이름처럼 "충돌이 없을 것"이라 가정하고 동작하며, 락을 걸지 않고 데이터를 처리한 뒤 최종 커밋 시점에 버전 번호를 비교하여 충돌 여부를 판단한다. 충돌이 발생하면 해당 트랜잭션을 롤백시키고, 애플리케이션에서는 이를 감지해 재시도 로직으로 처리하는 방식이다.

@Version
private Long version;
public void likeWithRetry(Long postId, String userName) {
        int retry = 0;
        long startTime = System.currentTimeMillis();
        while (retry < 10) {
            try {
                like(postId, userName);
                long endTime = System.currentTimeMillis();
                logSuccess(userName, retry, endTime - startTime);
                return;
            } catch (ObjectOptimisticLockingFailureException e) {
                retry++;
                System.out.println("🔁 낙관적 락 재시도 #" + retry);
            }catch (SnsApplicationException e) {
                long endTime = System.currentTimeMillis();
                logFailure(userName, retry, endTime - startTime, e.getMessage());
                return;
            }
        }
        long endTime = System.currentTimeMillis();
        logFailure(userName, retry, endTime - startTime, "재시도 초과");
    }
    private void logSuccess(String userName, int retry, long durationMs) {
        System.out.printf("[Thread-%s] ✅ 성공 | 재시도: %d회 | 소요시간: %dms%n", userName, retry, durationMs);
    }

    private void logFailure(String userName, int retry, long durationMs, String reason) {
        System.out.printf("[Thread-%s] ❌ 실패 | 재시도: %d회 | 소요시간: %dms | 예외: %s%n", userName, retry, durationMs, reason);
    }

    @Transactional
    public void like(Long postId, String userName) {

        UserEntity userEntity = getUserEntityOrException(userName);
        PostEntity postEntity = getPostEntityOrException(postId);

        // 불필요한 데이터 로딩을 줄여 성능과 명확성을 높이기 위해 리팩토링
        boolean alreadyLiked = likeEntityRepository.existsByUserAndPost(userEntity, postId);
        if (alreadyLiked) {
            throw new SnsApplicationException(ErrorCode.ALREADY_LIKED, String.format("userName %s already like post %d", userName, postId));
        }

        postEntity.incrementLikeCount();
        postEntityRepository.saveAndFlush(postEntity);
        likeEntityRepository.save(LikeEntity.of(userEntity, postEntity));
        alarmEntityRepository.save(AlarmEntity.of(postEntity.getUser(), AlarmType.NEW_COMMENT_ON_POST, new AlarmArgs(userEntity.getId(), postEntity.getId())));
    }

Post 엔티티에 위와 같이 @Version필드를 추가하면, JPA가 자동으로 버전 값을 관리하게 된다.

이후 충돌 시에는 ObjectOptimisticLockingFailureException 예외처리가 발생한다.

 

잠깐! 왜 비관적 락이 아닌 낙관적 락을 사용했나요?

더보기

동시성 이슈를 해결하기 위해 고려할 수 있는 방법에는 비관적 락(Pessimistic Lock) 낙관적 락(Optimistic Lock)이 있다.
두 접근 방식은 각각의 장단점이 있으며, 나는 다음과 같은 이유로 낙관적 락을 선택했다.

 

1. 좋아요는 반드시 성공해야 하는 연산이 아니다

좋아요는 결제나 재고 차감처럼 실패가 곧 서비스 오류로 이어지는 연산이 아니기 때문에, 충돌이 발생하더라도 재시도만으로 충분히 복구 가능하다. 이처럼 실패 허용이 가능한 연산에서는 락 비용이 높은 비관적 락보다는 낙관적 락이 더 적합하다.

 

2. 비관적 락은 성능을 크게 제한한다

비관적 락은 SELECT ... FOR UPDATE와 같이 읽는 시점부터 트랜잭션에 락을 걸어 다른 쓰기 요청을 차단한다. 이 구조는 동시성을 강하게 제어하지만, 다음과 같은 문제를 유발한다:

  • 충돌 가능성이 낮은 상황에서도 모든 요청에 대해 락을 획득하고 해제하는 비용이 지속적으로 발생
  • 락이 걸려 있는 동안, 다른 쓰기 트랜잭션은 대기하거나 타임아웃
  • 결국 서비스 처리량(Throughput)이 감소하고, 성능 병목이 발생

이 점에서 보면, 오히려 충돌이 자주 발생하지 않는 상황일수록 비관적 락은 불필요한 자원 낭비가 될 수 있다.

 

3. 비관적 락도 데드락에서 완전히 자유롭지는 않다

잠깐은 “정책이 명확한 비관적 락을 사용해 확실하게 동시성을 제어하는 게 낫지 않을까?”라는 고민도 했지만, 비관적 락도 락 획득 순서에 따라 데드락에서 완전히 자유로울 수는 없다. 즉, 락을 명시적으로 걸었다고 해서 항상 더 안전한 건 아니다.

 

4. 낙관적 락은 정합성과 성능을 모두 만족시킨다

낙관적 락은 기본적으로 락을 걸지 않기 때문에 성능이 우수하고, 충돌 시에만 예외를 발생시켜 재시도로 복구할 수 있다. 특히 이번 좋아요 기능처럼 충돌 가능성이 상대적으로 낮고, 요청 빈도가 높은 API에는 적절한 선택이었다.

like()와 likeWithRetry()를 분리한 이유는?

더보기

낙관적 락을 적용하며 가장 고민했던 부분 중 하나는, 낙관적 락 예외 발생 시 재시도 로직을 어디에 둘 것인가였다. 처음엔 @Transactional이 붙은 like() 메서드 안에서 while 문으로 재시도를 구현하려 했지만, 이는 JPA의 트랜잭션 처리 방식과 맞지 않는 위험한 방식이라는 걸 곧 깨달았다.

낙관적 락의 예외인 ObjectOptimisticLockingFailureException은 트랜잭션 커밋 시점에 발생한다. 즉, 트랜잭션이 이미 실패하고 롤백된 상태에서 재시도를 하게 되면, JPA의 영속성 컨텍스트는 더 이상 유효하지 않으며 새로운 트랜잭션을 시작하지 않으면 데이터 일관성이 깨질 수 있다.

이런 이유로, 나는 트랜잭션 외부에서 재시도 로직을 관리하는 likeWithRetry() 메서드를 별도로 분리했다.

  • like()는 오직 좋아요를 처리하는 도메인 핵심 로직에 집중하도록 하고,
  • likeWithRetry()는 낙관적 락 충돌 시 재시도 정책과 트랜잭션 재시작을 담당한다.

이러한 분리는 코드의 책임을 명확히 하고, 유지보수성과 테스트 편의성도 함께 확보할 수 있다. 또한, like()는 테스트 코드나 관리자 기능 등 다양한 환경에서 재시도 없이도 단독 호출 가능하도록 만들어 유연성을 높였다.

낙관적 락은 단순히 @Version만 붙인다고 끝나는 게 아니라, 예외를 어디서 감지하고 어떻게 복구할 것인가에 대한 구조적인 고민이 반드시 동반되어야 한다. 나는 그 고민의 결과로 like()와 likeWithRetry()를 분리하게 되었고, 결과적으로 낙관적 락의 장점을 온전히 활용하면서도 안정적인 재시도 흐름을 확보할 수 있었다.

낙관적 락 적용 이후

낙관적 락 기반으로 리팩토링한 이후, 데드락은 더 이상 발생하지 않았다.
트랜잭션 간에 서로의 락을 기다리며 교착 상태에 빠지던 문제는, @Version 필드를 통해 충돌을 감지하고 명시적으로 롤백 및 재시도하도록 설계하면서 해소되었다.

하지만 데드락을 제거한 대가로, 성공률과 응답 시간이 불안정해지는 현상이 새롭게 나타났다.

스레드 수 최대
재시도 수
총 요청  성공 실패  성공률 평균 재시도 최대 재시도 평균 응답 시간(ms) 최대 응답 시간(ms)
3 5 3 3 0 100% 1.0 2 1206 1208
20 10 20 13 7 65% 4.8
(13번 기준)
9 3983 5506
50 10 50 17 33 34% 6.8
(17번 기준)
8 9728 12,334

 

낙관적 락의 한계

낙관적 락은 DB 락을 직접 걸지 않기 때문에 데드락은 피할 수 있었지만, 충돌 자체는 빈번하게 발생했다.
이로 인해 다음과 같은 문제가 나타났다:

  • @Transactional 범위 안에서 PostEntity를 조회하고 like_count를 증가시킨 후 저장하는 과정에서 동일한 엔티티에 대한 버전 충돌이 반복됨
  • 충돌 발생 시 예외가 발생하고, 애플리케이션은 이를 감지해 재시도 루프를 수행
  • 사용자 수(=경합 수준)가 높아질수록 재시도 횟수와 응답 시간은 급격히 증가하고, 성공률은 반대로 감소

정리

  • 장점: 데드락 완전 해결, DB 락 없이 충돌 제어 가능
  • 단점: 충돌 빈번 시 재시도 비용 증가 → 트래픽이 몰리면 낮은 성공률 + 긴 응답 시간


3. 좋아요 기능의 최종 구조: Redis 캐시 + Kafka 비동기 이벤트 처리

– 좋아요는 락이 아닌 이벤트다

도입 전 상황

초기에는 Post.like_count 컬럼을 직접 업데이트하는 반정규화 방식으로 좋아요 수를 관리했다.
하지만 여러 사용자가 동시에 좋아요를 누를 경우, InnoDB 내부의 잠금 충돌로 인해 데드락이 발생했고, 이를 피하기 위해 낙관적 락을 도입했지만 여전히 다음과 같은 한계는 존재했다.

 

  • @Version 필드로 버전 충돌을 감지하고, 충돌 시 재시도하도록 처리
  • 데드락은 사라졌지만, 많은 요청 발생으로 재시도 횟수 초과 시, 성공률 저하와 응답 지연 문제 발생

이에 Redis의 Lua Script를 통해 원자적 연산을 함으로써 동시성 문제와 비교적 신속한 응답을 이끌어낼 수 있다고 생각했지만 다른 구조로의 전환을 통해 현재의 문제를 해결할 수 있겠다는 생각이 들었다.

구조 전환: Redis + Kafka 아키텍처

좋아요는 표면적으로는 단순한 +1 연산처럼 보이지만, 실제 트래픽 환경에서는 동시에 수많은 사용자가 같은 게시글에 좋아요를 누르는 고밀도 이벤트입니다.

이전 단계에서는 정확한 정합성을 위해 락을 기반으로 처리했지만, 트래픽이 많아질수록 락은 오히려 성능 병목과 실패 확률 증가라는 비용을 초래했습니다.

이 과정에서 깨달은 핵심은 좋아요는 요청이 많아질수록 실시간으로 정확히 저장되어야 하는 데이터라기 보단 누가 눌렀는지를 나중에 수집해서 저장해도 무방한 이벤트라는 점입니다.

실제로 많은 SNS 서비스들도 비슷한 판단으로 ‘실시간 정합성’을 보단 ‘지연 정합성’을 택한 구조로 진화해왔다고 생각합니다.

  • 락을 걸어 정확한 수를 유지하기보다,
  • 사용자 요청을 빠르게 수신하고(캐시),
  • 비동기적으로 이벤트를 수집한 뒤(Kafka)
  • 나중에 DB에 반영하는 구조가 훨씬 현실적이고 확장성 높은 선택이었던 것입니다.

이러한 구조는 다음과 같은 장점을 제공합니다:

1. 사용자 응답 속도 : 즉시 Redis에서 처리하므로 빠른 피드백을 받을 수 있다.

2. 서버 부하 : 락을 제거함으로써 데드락 혹은 재시도 비용을 줄일 수 있다.

3. 시스템 확장성 : Kafka 기반으로 분산 소비가 가능하다.

4. 데이터 정합성 : 약간의 지연을 허용한다.

 

즉, 트래픽이 많아질수록 락보다는 큐가, 정합성보다는 성능이 중심이 되는 방향으로 좋아요 기능은 자연스럽게 진화하게 된 것입니다.

설계 개편: Redis 캐시 + Kafka 이벤트 기반 구조

새로운 처리 흐름 요약

  1. 사용자가 좋아요 요청
  2. Redis Set으로 중복 확인
  3. Redis like count INCR로 증가
  4. Kafka에 LikeEvent 비동기 발행
  5. Kafka Consumer가 실제 DB에:
    • LikeEntity 저장

Redis의 역할

Redis Key설명
post:{postId}:likes:users 좋아요 누른 유저 Set → 중복 감지 및 필터링
post:{postId}:likes:count 좋아요 수를 저장하는 키 → Atomic INCR로 정합성 확보
  • 매우 빠른 응답 속도
  • 락 없이도 동시성 안전 (Redis는 단일 스레드 기반으로 연산 순서 보장)
  • TTL 설정으로 캐시로서 유연하게 동작

Kafka의 역할

  • Kafka 토픽: like-events
  • 이벤트: LikeEvent(postId, userId)
  • Consumer:
    • 실제 LikeEntity 생성

테스트 결과 비교 요약

항목낙관적 락 구조 (50명)Redis + Kafka 구조 (50명)
요청 수 50회
성공률 34% 100%
평균 응답 시간 9,728ms 1,130ms
최대 응답 시간 12,334ms 2,380ms
재시도 횟수 최대 8회 없음
구조 특징 @Version 기반의 동기 트랜잭션 처리 비동기 이벤트 큐 처리 + 캐싱 구조

Redis + Kafka 구조의 성능 저하 원인

  • 현재 구조에서는 매 요청마다 회원 정보를 DB에서 조회하고 있음.
  • 이로 인해 Redis + Kafka 구조임에도 불구하고 예상보다 평균 응답 시간이 높게 측정됨.
  • 게시글 처리와는 무관한 부수적인 DB I/O가 전체 성능을 저하시키는 병목으로 작용함.

개선 방안 : 회원 정보 Redis 캐싱

  1. 회원 정보 조회 방식 개선
    • 기존: DB → 회원 정보 조회 (매 요청)
    • 개선: Redis → 회원 정보 캐싱 조회, 없을 경우 DB → Redis 저장
  2. 캐싱 전략
    • Key 예시: member:{memberId}
    • TTL 설정: 10분 ~ 1시간 (변동 가능성에 따라)
    • 로그인 또는 회원 정보 수정 시 캐시 무효화 처리 포함
  3. 기대 효과
    • DB 부하 감소 및 평균 응답 시간 단축
    • Kafka 처리 자체는 빠르기 때문에, 비즈니스 부하만 줄이면 전체 성능이 선형적으로 개선됨

마무리

- 좋아요를 통해 본 설계의 깊이

좋아요 기능을 구현하면서 나는 단순히 UI에 반응하는 로직을 만든 것이 아니라, 서비스가 성장함에 따라 시스템이 감당해야 할 기술적 문제들을 마주하게 되었다. 처음엔 정규화된 테이블과 단순 쿼리로 시작했지만, 트래픽이 증가하면서 조회 병목, 데드락, 락 충돌, 낙관적 락의 한계를 차례로 경험했다.
그리고 결국 깨달은 것은, 좋아요는 정확한 수치보다 빠르고 안정적인 응답이 중요한 이벤트라는 사실이었다.
이 과정에서 정합성과 성능 사이의 트레이드오프, 그리고 락 기반 트랜잭션 처리와 큐 기반 이벤트 처리의 차이를 실감할 수 있었다.

 

이 글은 내가 좋아요 기능을 통해 어떤 기술을 선택하고, 어떤 문제를 경험하고, 어떤 방식으로 설계를 전환했는지를 기록한 여정이다. 단순한 기능도 트래픽 환경과 동시성을 고려하면 얼마나 많은 판단과 구조적 선택이 필요한지를 깊이 체감할 수 있었다.

 

기능을 만드는 개발자에서, 구조를 고민하는 개발자로 한 걸음 나아가는 경험이었다.

'프로젝트' 카테고리의 다른 글

Nginx  (0) 2024.08.09
코드 테스트  (0) 2024.08.01
EC2 인스턴스 메모리 모니터링  (0) 2024.07.30
JMeter를 활용한 성능 테스트(1)  (0) 2024.07.28
성능테스트의 중요성  (0) 2024.07.27