새소식

스프링

Facade 계층으로 코드 갈아엎기

  • -

안녕하세요.

 

문제는 그렇게 시작됐습니다.

 

@Transactional
public void saveComment(String userId, Long diaryId, CommentRequest commentRequest) {
    Member member = getMember(userId);
    Diary diary = getDiary(diaryId);

    List<Member> acceptedMembers = friendCheckService.getFriendMembers(diary.getMember().getUserId());

    if (!diary.isWriter(member) && !acceptedMembers.contains(member)) {
        throw new UnauthorizedException("해당 일기에 댓글을 작성할 권한이 없습니다. 본인이거나 친구일 경우에만 작성이 가능합니다.");
    }

    Comment comment = Comment.builder()
            .member(member)
            .content(commentRequest.content())
            .diary(diary)
            .build();

    commentRepository.save(comment);

    pointService.earnPointByType(new PointRequest(member, PointType.COMMENT));

    String senderId = userId;
    String receiverId = diary.getMember().getUserId();
    // 알림 전송
    if (!senderId.equals(receiverId)) { // 자신에게 자신이 댓글 알림을 보내는 것이 아닌 경우에만 알림 전송
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                notificationService.publishCommentNotification(senderId, receiverId, "comment", diary.getId(), diary.getCreatedAt());
            }
        });
    }
}

 

저희 서비스 코드인데요, 친구 일기 드가서 댓글 달면 포인트를 주거덩요. 근데 뭔가 그날따라 눈이 침침해서 그런지 기분이 별로 안좋아서 그랬는지 그냥 뭐 그랬는지 코드가 너무 복잡한 느낌을 받았습니다.

 

분명히 댓글 저장인데 댓글도 저장하고 포인트도 얻고 알림도 전송하고 굉장히 코드가 바빠보이네요.

 

보기만 힘든거면 다행입니다. 이게 싹다 한 트랜잭션에 묶여있어서 댓글 저장은 됐는데 포인트 획득이 실패하면 댓글 저장도 실패하겠죠?

 

그럼 유저한테 뭐라 설명하면 좋을까요?

포인트 획득 할 때 네임드락 획득을 실패해서 당신의 댓글 저장이 실패했다.

 

네 뭐 그럴거 같습니다.

 

즉 핵심 로직인 댓글 저장과 부가 로직인 (물론 부가 로직도 중요하지만) 포인트 획득 그리고 알림 로직까지 깔끔하게 갈아엎도록 하겠습니다.

 

시작

 

지금 코드는 서비스 단에 다 묶여 있죠. 이를 위해 Facade 계층을 도입하겠습니다.

 

Facade 계층은 SRP 준수를 위해 도입했습니다. 아래 코드를 보시죠.

 

@RequiredArgsConstructor
@Component
public class CommentFacade {

    private final CommentService commentService;
    private final ApplicationEventPublisher eventPublisher;

    public void saveComment(String userId, Long diaryId, CommentRequest request) {
        Comment comment = commentService.saveComment(userId, diaryId, request);
        eventPublisher.publishEvent(NewCommentEvent.of(comment));
    }
}

 

Controller단과 Service단 사이에 Facade가 들어가고 중간에서 작업들을 중재해준다고 보면 됩니다.

 

이렇게만 정리해주면 Service의 코드는

@Transactional
public Comment saveComment(String userId, Long diaryId, CommentRequest commentRequest) {
    Member member = getMember(userId);
    Diary diary = getDiary(diaryId);

    List<Member> acceptedMembers = friendCheckService.getFriendMembers(diary.getMember().getUserId());

    if (!diary.isWriter(member) && !acceptedMembers.contains(member)) {
        throw new UnauthorizedException("해당 일기에 댓글을 작성할 권한이 없습니다. 본인이거나 친구일 경우에만 작성이 가능합니다.");
    }

    Comment comment = Comment.builder()
            .member(member)
            .content(commentRequest.content())
            .diary(diary)
            .build();
    return commentRepository.save(comment);
}

 

너무 깔끔해졌죠. 이제 정말 댓글 저장 역할만 하는 것 같습니다.

 

그럼 포인트 획득과 알림 로직은 어디로 간거죠?

 

싹 다 이벤트 기반 비동기 처리로 돌렸습니다. 그 이유는

1. 핵심 로직이 아니므로 트랜잭션 분리가 필요함. 성능과 오류 격리 가능

2. 추후 확장하거나 관리하기 용이함

 

자 그럼 Facade 계층에서 댓글을 저장하고 트랜잭션이 끝나면서 커밋됩니다.

이제 부가 로직들이 실패해도 댓글에는 영향이 없을 겁니다.

 

그리고 이벤트들을 발행하면서 포인트 획득 로직과 알림 기능이 실행됩니다.

 

// 포인트 획득 로직 (PointEarningListener)
@Async
@EventListener
public void handleCommentSaved(NewCommentEvent event) {
    pointFacade.earnPointByType(PointRequest.of(event.getComment().getMember(), PointType.COMMENT));
}

// 알림 발행 로직 (NewCommentNotificationListener)
@Async
@EventListener
public void handleCommentSaved(NewCommentEvent event) {
    Comment comment = event.getComment();
    String senderId = comment.getMember().getUserId();
    String receiverId = comment.getDiary().getMember().getUserId();
    Long diaryId = comment.getDiary().getId();
    Instant createdAt = comment.getCreatedAt();

    PublishNotificationRequest request = PublishNotificationRequest.of(senderId, receiverId, "comment", diaryId, createdAt);

    notificationService.publishNotification(request);
}

 

이런식으로 나눴는데요, 네임드락을 사용해 동시성을 처리하도록 한 포인트 획득 로직을 더 자세히 보겠습니다.

 

처음엔 포인트 처리를 위해 네임드락을 획득하는 도중에도 트랜잭션을 잡고 있었는데요, 이러면 커넥션풀이 쓸대없이 낭비되겠죠.

 

네임드락을 획득한 이후에 트랜잭션을 걸도록 하고 락 획득 및 해제 로직도 공통화해서 처리해보겠습니다.

@RequiredArgsConstructor
@Component
public class LockExecutor {

    public static final int DELAY = 100;
    public static final int MULTIPLIER = 2;

    private final LockWithMemberFactory lockWithMemberFactory;

    @Retryable(
            value = {
                    LockException.class,
                    DeadlockLoserDataAccessException.class
            },
            backoff = @Backoff(delay = DELAY, multiplier = MULTIPLIER, random = true)
    )
    public void executeWithLock(String lockPrefix, Member member, Runnable runnable) {
        String lockKey = lockPrefix + member.getId();
        Lock lock = null;
        try {
            lock = lockWithMemberFactory.tryLock(member, lockKey, 10, 2);
            runnable.run();
        } finally {
            if (lock != null) {
                lockWithMemberFactory.unlock(member, lock);
            }
        }
    }
}

 

락 관련 로직이 재사용 될 수 있도록 했고 적절한 백오프 전략을 적용해서 재시도 로직도 적용하였습니다. 처음엔 100ms만큼 대기하고 그 후로 2배씩 대기시간을 늘려가면서 그 사이에서 랜덤 시간을 고른다는거죠.

 

실제로 그래서 사용할 땐

public void earnPointByType(PointRequest pointRequest) {
    Member member = pointRequest.member();
    lockExecutor.executeWithLock(
            LOCK_PREFIX,
            member,
            () -> pointService.earnPointByType(pointRequest)
    );
}

 

이런식으로 사용이 가능해졌습니다.

 

정리하자면

 

1. Facade 계층 도입으로 SRP(단일 책임 원칙) 준수

2. 이벤트 기반 비동기 처리로 핵심 로직과 부가 기능 분리

3. 분산 락 획득과 트랜잭션 시작 시점 분리

 

입니다.

 

그럼 이렇게 해주면 성능이 좋아질까요? 그럴수도 있고 아닐 수도 있습니다.

이렇게 비동기로 처리해줘도 스레드풀에 충분히 할당되어 있지 않으면 대기큐에 쌓여서 오히려 처리가 지연될 수 있거덩요.

너무 많아도 과부하 걸리거나 컨텍스트 스위칭이 과도하게 일어나서 성능이 안좋아질 수 있겠죠?

저는 k6를 통해서 적정 스레드 풀 개수를 찾아줬습니다.

 

저는 스레드풀 60개에서 최적의 성능이 나오더라구요.

 

어쨌든 k6를 사용하여 부하테스트 진행해 보았습니다.

 

import http from 'k6/http';
import { check, sleep } from 'k6';

const BASE = 'http://localhost:8080/api/v1/study/';
const urls1 = Array.from({ length: 100 }, (_, i) => `${BASE}${i}`);
const urls2 = Array.from({ length: 100 }, (_, i) => `${BASE}consume/${i}`);

export const options = {
    vus: 100,          // 동시 사용자 100명
    duration: '10m',   // 10분 지속
};

export default function () {
    const idx = (__VU - 1) % urls1.length;

    let responses = http.batch([
        {
            method: 'GET',
            url: urls1[idx]
        },
        {
            method: 'GET',
            url: urls2[idx]
        },
    ]);

    check(responses[0], { '일기 작성 성공': (r) => r.status === 200 });
    check(responses[1], { '포인트 소비 성공': (r) => r.status === 200 });

    sleep(1);          // 1초 간격(초당 1요청/VU)
}

 

사용자 10명이 락 경합을 벌이는 시나리오를 포함시키도록 하여 진행해보았습니다.

 

# Before
> K6_WEB_DASHBOARD=true k6 run k6/k6-script.js


         /\      Grafana   /‾‾/  
    /\  /  \     |\  __   /  /   
   /  \/    \    | |/ /  /   ‾‾\ 
  /          \   |   (  |  (‾)  |
 / __________ \  |_|\_\  \_____/ 


     execution: local
        script: k6/k6-script.js
 web dashboard: [http://127.0.0.1:5665](http://127.0.0.1:5665)
        output: -


     scenarios: (100.00%) 1 scenario, 100 max VUs, 10m30s max duration (incl. graceful stop):
              * default: 100 looping VUs for 10m0s (gracefulStop: 30s)




  █ TOTAL RESULTS 


    checks_total.......................: 10738   17.563733/s
    checks_succeeded...................: 100.00% 10738 out of 10738
    checks_failed......................: 0.00%   0 out of 10738


    ✓ 일기 작성 성공
    ✓ 포인트 소비 성공


    HTTP
    http_req_duration.......................................................: avg=9.7s   min=183.08ms med=9.52s  max=20.03s p(90)=10.47s p(95)=10.57s
      { expected_response:true }............................................: avg=9.7s   min=183.08ms med=9.52s  max=20.03s p(90)=10.47s p(95)=10.57s
    http_req_failed.........................................................: 0.00%  0 out of 10738
    http_reqs...............................................................: 10738  17.563733/s


    EXECUTION
    iteration_duration......................................................: avg=11.28s min=2.49s    med=11.31s max=20.91s p(90)=11.56s p(95)=11.61s
    iterations..............................................................: 5369   8.781866/s
    vus.....................................................................: 9      min=9          max=100
    vus_max.................................................................: 100    min=100        max=100


    NETWORK
    data_received...........................................................: 3.5 MB 5.7 kB/s
    data_sent...............................................................: 955 kB 1.6 kB/s





running (10m11.4s), 000/100 VUs, 5369 complete and 0 interrupted iterations
default ✓ [======================================] 100 VUs  10m0s
~/Doc/4th-SC-TEAM1-BE main !9 ?4 >                                                                    10m 12s py base 17:35:36


# After (thread = 60)
> K6_WEB_DASHBOARD=true k6 run k6/k6-script.js


         /\      Grafana   /‾‾/  
    /\  /  \     |\  __   /  /   
   /  \/    \    | |/ /  /   ‾‾\ 
  /          \   |   (  |  (‾)  |
 / __________ \  |_|\_\  \_____/ 


     execution: local
        script: k6/k6-script.js
 web dashboard: [http://127.0.0.1:5665](http://127.0.0.1:5665)
        output: -


     scenarios: (100.00%) 1 scenario, 100 max VUs, 10m30s max duration (incl. graceful stop):
              * default: 100 looping VUs for 10m0s (gracefulStop: 30s)




  █ TOTAL RESULTS 


    checks_total.......................: 73836   122.840641/s
    checks_succeeded...................: 100.00% 73836 out of 73836
    checks_failed......................: 0.00%   0 out of 73836


    ✓ 일기 작성 성공
    ✓ 포인트 소비 성공


    HTTP
    http_req_duration.......................................................: avg=332.9ms min=2.14ms med=18.19ms max=8.91s p(90)=1.19s p(95)=1.25s
      { expected_response:true }............................................: avg=332.9ms min=2.14ms med=18.19ms max=8.91s p(90)=1.19s p(95)=1.25s
    http_req_failed.........................................................: 0.00%  0 out of 73836
    http_reqs...............................................................: 73836  122.840641/s


    EXECUTION
    iteration_duration......................................................: avg=1.62s   min=1s     med=1.05s   max=9.91s p(90)=2.24s p(95)=2.32s
    iterations..............................................................: 36918  61.420321/s
    vus.....................................................................: 14     min=14         max=100
    vus_max.................................................................: 100    min=100        max=100


    NETWORK
    data_received...........................................................: 28 MB  46 kB/s
    data_sent...............................................................: 6.6 MB 11 kB/s





running (10m01.1s), 000/100 VUs, 36918 complete and 0 interrupted iterations
default ✓ [======================================] 100 VUs  10m0s
~/Doc/4th-SC-TEAM1-BE main !9 ?4 >                                                                    11m 16s py base 17:48:28

 

 

중요한 지표들 몇개 정리해보면 이렇습니다.

 

평균 응답시간 9.70 s 0.333 s ▼ 96.6%(29 × 빠름)
p90 10.47 s 1.19 s ▼ 88.6%
p95 10.57 s 1.25 s ▼ 88.2%

 

응답시간이 대폭 줄어들었고 하위 10%, 5%에 대한 기록인 p90, p95의 시간도 대폭 줄어든 것을 볼 수 있습니다.

 

처리된 요청 개수를 보니 10738개에서 73836으로 TPS도 6배쯤 늘어난 것 같습니다.

 

예이~~~~~~~~~~~

 

마치겠습니다

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.