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배쯤 늘어난 것 같습니다.
예이~~~~~~~~~~~
네
마치겠습니다
'스프링' 카테고리의 다른 글
직접 재시도 로직 구현했는데 알고 보니 이미 있는 리얼 허거덩거덩스한 상황 (6) | 2025.08.01 |
---|---|
포인트 보정 배치? 트랜잭션 최적화? 너 꿈꿨니? (3) | 2025.07.31 |
커스텀 어노테이션과 AOP 사용해서 공통기능 처리하기 (2) | 2025.06.30 |
[매일메일] private 메서드에 @Transactional 선언하면 트랜잭션이 동작할까? (0) | 2025.02.07 |
[매일메일] 단위 테스트 vs 통합 테스트 (0) | 2025.01.27 |
소중한 공감 감사합니다