728x90

길다면 길고 짧다면 짧은 항해 플러스 백엔드 10주 차 과정을 마무리하였습니다.

 

돌이켜 보면 그 10주 동안 참 많은 일들이 있었다 느껴지네요.

 

첫 발제를 듣던 토요일,

어떻게 해야 할지 고민하는 일요일,

출근하면서 과제해야 하는구나 절망의 월요일,

멘토링 시간이 얼마 안 남았구나 화요일,

과제 제출일이 얼마 안 남았구나 수요일,

내일 제출이니 오늘은 밤새어서 끝내야지 목요일,

제출 완료했으니 정리 좀 해볼까? 금요일

 

일주일이란 시간 동안 참 알차게 보냈다 생각이 드네요.

 

많이 힘들었던 거 같네요.

지금 생각해 봐도 개인적으로 힘든 시간이었고, 출근하면서 과제를 수행하는 것은 또 하나의 고행이라고 느껴지기도 했습니다.

 

끝난 지 거의 1주가 다 되어가는 지금도 힘듬의 여파가 남아 있는 거 같네요.

 

근데!

후회는 전혀 없고, 오히려 앞날이 기대가 되기 시작했어요.

 

오늘 새로운 혹은 다른 분들이 어떻게 쓰는지 알 수 있겠구나 기대된다 토요일,

감사한 피드백을 토대로 이해하며 리펙토링 해보자 일요일,

우리 모두 같이해봐요 으쌰으쌰 월요일,

모르던걸 물어볼 기회가 생겼어!! 이참에 열심히 물어보고 고민해 봐야겠다! 화요일과 수요일,

오늘은 드디어 마지막 날이다! 파이팅 해서 마무리해 봐야지!! 패스받고 말겠어! 목요일,

오늘 잘 제출한 거 같아요 드디어 해방이다~~ 금요일

 

너무 재밌고 소중했던 일주일 시간이 보이네요.

너무나도 소중한 1조, 분위기 메이커가 많았던 3조, 4조, 5조.

 

그곳에만 가면 마음이 편해진다 10조. ( 자주 방문한 조들입니다.. 모두 사랑해요. )

너무나도 소중한 동료들과 같이 힘내서 일주일 동안 열심히 과제를 수행한 일주일 총 10주였습니다.

물론 힘들었죠, 하지만 믿고 따라갈 수 있는 코치님들과 동료들이 있으니 저는 너무나도 재밌는 과정이었던 거 같네요.

 

이런 과정을 수행한 저는 올해로 3년 차 백엔드 개발자 이건입니다.

 

저는 한 회사에서 현재 3년 차로 근무 중이고, 실제 업무는 풀스택을 수행하고 있습니다...

 

업무를 진행하면서 회사에서 사용하는 아키텍처, 개발문화, 코딩 방법, 소통방법 등등 저는 이 모든 것들이 외부 즉 지금 있는 환경 밖에서 어떻게 보일지 너무 궁금한 개구리였습니다.

 

회사의 바쁜 업무로 인해 아침 출근 새벽 퇴근을 밥먹듯이 하는 저는 성장이 너무나도 고픈 개발자였습니다.

 

하지만 이때 고민이 생겼습니다.

 

과연 내가 공부만 해서 계속 쌓여만 가는 연차를 커버할 수 있는 개발자인가??

또 좋은 리더급으로 성장하고 있는 중인가??

나는 열심히  공부하고, 소통하고, 개발문화를 만들어보고 싶어 노력을 하는데 이게 맞는 걸까??

 

여러분은 어떠신가요?

저와 같은 고민을 하시진 않더라도 이 글을 읽고 계신다면 비슷한 고민을 한 번쯤은 해보셨을 것이라 생각이 듭니다.

 

이런 고민을 해결할 수 있는 방법을 찾던 중 저는 아래와 같은 방법들을 고민해 봅니다.

  1. 부트캠프를 들어가 본다.
  2. 다른 개발자에게 커피챗을 신청해 본다.

너무 적은 선택지를 고민해 본 거겠죠?

 

하지만 개발자 세상을 잘 모르는 저는 위 두 방법밖에는 몰랐습니다.

 

그래서 보면 첫 번째 방법 "부트캠프를 들어가 본다." 흠...

경력이 쌓이면서 부트캠프를 반복적으로 들어가 6개월을 낭비해야 하나??

그게 바로 물경력 아닌가??라는 생각을 해서 기각하였습니다.

 

두 번째 방법 "다른 개발자에게 커피챗을 신청해 본다." 흠...

어떻게 해야 하지?? 괜히 바쁘신데 민폐 아닌가??

괜히 이야기하는데 내가 너무 준비 안되어있으면 어떡하지??

 

위와 같이 저는 두 가지 방법 전부 제대로 소화해 낼 자신이 없었습니다.

부트캠프는 퇴사를 해야 하고, 커피챗은 너무 부담이 되고...

 

그렇게 고민하던 중 항해 99를 보게 되었습니다.

항해 99는 원래 알고 있던 곳이었습니다.( 신입으로 들어오신 분들이 잘하셔서 )

 

그래서 항해 99에서 뭐 할 거 있나?? 호기심에 들어가 봤더니 거기에 재직자 대상으로 진행하는 코스가 있던 것이었습니다!

 

커리큘럼도 보니 TDD, 클린 아키텍처, Kafka, Redis, Mysql 와우!

평소 사용하고 있지만 제대로 이해하지 못한 것들, 그리고 배우고 싶은 내용들이 있었던 것이었습니다.

 

또한 돈을 내고 배우는 것이다 보니 무료 부트캠프처럼 아무나 오는 것이 아닌 성장에 목이 마른 분들이 오실 것이라 생각하고 바로 신청을 해버렸습니다.

 

들어와 보니 단계별로 실력을 인증한다네??

심지어 블랙 배지는 이뻤습니다.

 

그렇게 이쁜 블랙 배지를 얻고, 기본기도 얻고 다 얻어가기 위해 시작한 항해였습니다.

 

막상 시작하니 토요일마다 그 주 과제를 발제해 주는데 와 토론하고, 고민하고, 설득하고, 발제 듣고 와 저는 천국인 줄 알았습니다.왜 이렇게 재밌는 건지 요즘 개발이 재미없다 생각했는데 와 너무 재밌는 거예요?

 

이렇게 많은 개발자가 모이니 너무 재밌게 커뮤니케이션을 하게 된다라는 걸 처음 느껴본 거 같아요.

 

너무너무 재밌게 하고, 과제를 수행하는데 시니어 코치진의 멘토링 시간이 다가왔습니다.

이때는 제가 한 일, 고민한 흔적, 질문 등을 피드백받을 수 있는 시간인데요, 코치님들도 정말 어쩜 이렇게 다 다르신지...

 

선호하는 개발 스타일이 다른 게 그렇게 헷갈릴 줄 몰랐지 뭐예요.

하지만 단하나는 공통 사항이 있었는데, 그것은 개발에 진심인 분들이라는 것이었습니다.

 

단순히 나 개발 잘해가 아니고 눈앞에서 같이 고민해 보며 문제를 처리하는 과정을 보여주시고, 우리는 그것을 보면서 다른 방법은 없을까 고민을 해보고 정말 순환이 참 잘되는 구조였다고 생각합니다.

 

이렇게 좋은 코치님들과 동료들과 같이하니 시간도 후딱 가버리고...

첫째, 중간, 마지막에 술도 거하게 마셔주고...( 전 안 마셨어요 )

 

이런 시간이 지나고 나니 저에게 많은 변화가 있는 것을 느꼈습니다.

 

주변에 드디어 같이 고민을 나누고 같이 개발을 할 수 있는 소중한 동료분들이 생긴 것이 가장 최우선 첫 빠따이고,

점점 지쳐가는 개발자 인생에 또다시 어마어마한 활력소를 넣어준 것이 두 번째이요,

내가 해온 개발 기술들에 대해 설명할 수 있는 기본기를 갖추게 된 게 세 번째입니다.

 

거짓말 아니냐고요? 해보고 말씀하시기 바랍니다.

 

처음에는 꽤 비싸다고 생각하기도 했어요 거의 200에 가까운 돈이 나가니... 근데 첫 번째 동료들이 생겼다 전 여기서 100 이상의 값어치를 했다고 생각합니다.

 

그리고 코치님들과의 인연!! 이 인연 또한 제 앞날에 빛이 되어줄 인연이지 않을까요??

 

마지막으로 사람들 앞에 자신을 노출할 수 있는 자신감!!!!

물론 아직도 많이 떨리죠... 하지만 이를 극복하고 사람들 앞에 나서는 사람으로 발전되도록 노력할 수 있는 용기가 생긴 거 같아요.

 

또한 감사하게도 10주간 과제 통과율이 90프로가 넘어 블랙 배지를 얻을 수 있었습니다.

블랙 뱃지

 

여기까지 보시고도 항해 플러스 백엔드 교육과정을 듣는 것에 있어 고민 중이신가요??

 

바로 들어봐라!! 하고 싶은 마음도 있기는 하지만 신중하게 선택하시기 바랍니다.

 

저의 인생 터닝포인트가 이 지점이었지만, 여러분들의 터닝 포인트는 다른 곳에 있을 수도 있고, 세상은 넓고 할 수 있는 것도 많기 때문이죠!

 

많은 고민을 하고 선택하시기를 바라면서 여기서 글을 마무리 짓도록 하겠습니다.

 

읽어주셔서 감사합니다.

 

 


항해를 선택하기로 한 당신!!

  • 결제페이지 > 할인 코드 > 수료생 할인 코드 입력 시 20만 원 할인 적용 

위와 같이 하면 할인된다 하니 코드 필요하시면 쓰세요.


추천인 코드: VmJf7w

728x90

'항해99' 카테고리의 다른 글

[5주차 회고] 5주차를 마무리하며  (4) 2024.07.20
[1주차] 1주차 마무리...  (0) 2024.06.27
[플러스 백엔드 5기] 시작에 앞서  (2) 2024.06.15
728x90

소프트웨어는 다양한 상황에서 장애가 발생할 수 있습니다. 이러한 장애에 효과적으로 대응하기 위해서는 다음 세 가지 원칙을 반드시 기억하고 실천해야 합니다:

  1. 예방 가능한 장애를 방지할 것
    사전에 발생할 수 있는 문제를 예방하는 것이 최우선입니다.
  2. 장애 발생 시 신속하게 전파하고 해결할 것
    문제가 발생했다면 즉시 공유하고 빠르게 해결해야 합니다.
  3. 재발 방지 대책을 마련할 것
    동일한 장애가 반복되지 않도록 원인을 분석하고 조치를 취해야 합니다.

장애 발생에 대비하기 위해서는 사전 테스트를 통해 발생 가능한 상황을 시뮬레이션할 필요가 있습니다. 이를 위해 부하 테스트를 활용할 수 있습니다.

부하 테스트는 소프트웨어가 특정 시나리오에서 안정적으로 동작하는지를 검증하는 과정으로, 주요 목적은 다음과 같습니다:

  1. 예상 TPS(Transaction Per Second) 검증
    예상되는 초당 트랜잭션 처리량을 시스템이 제대로 처리할 수 있는지 확인합니다.
  2. 응답 시간 검증
    평균, 중앙값, 최대 응답 시간을 측정하여 성능을 평가합니다.
  3. 동시성 이슈 검증
    다량의 트래픽이 유입될 때 동시 처리에 문제가 없는지 확인합니다.

부하 테스트는 테스트 시간과 트래픽 양에 따라 다음 네 가지 유형으로 구분됩니다:

  1. Load Test (부하 테스트)
    • 시스템이 예상 부하를 정상적으로 처리할 수 있는지 평가합니다.
    • 특정 부하를 일정 시간 동안 가해 이상 여부를 파악합니다.
    • 목표 부하를 설정하고, 이에 맞는 애플리케이션 배포 스펙을 고려할 수 있습니다.
  2. Endurance Test (내구성 테스트)
    • 시스템이 장기간 안정적으로 운영될 수 있는지 평가합니다.
    • 장기간 부하가 가해졌을 때 발생할 수 있는 문제를 파악합니다.
    • 메모리 누수, 느린 쿼리 등 장기 운영 시 나타날 수 있는 숨겨진 문제를 발견합니다.
  3. Stress Test (스트레스 테스트)
    • 시스템이 증가하는 부하를 얼마나 잘 처리할 수 있는지 평가합니다.
    • 부하가 점진적으로 증가할 때 발생하는 문제를 파악합니다.
    • 장기적인 운영 계획과 확장성을 검토할 수 있습니다.
  4. Peak Test (최고 부하 테스트)
    • 일시적으로 많은 부하가 가해졌을 때 시스템이 잘 처리하는지 평가합니다.
    • 임계 부하를 순간적으로 제공했을 때 정상적으로 동작하는지 파악합니다.
    • 선착순 이벤트 등에서 정상적인 서비스 제공 가능 여부를 평가할 수 있습니다.

이번 부하 테스트에서는 일반적으로 많이 사용하는 Load Test와 Peak Test를 통해 시스템의 성능을 검증해 보도록 하겠습니다.

부하 테스트

시나리오 작성

부하 테스트를 진행하기 전 우선 서비스 특성에 맞춰 테스트를 진행할 상황을 먼저 생각해보아야 합니다.

  • 트래픽이 많이 몰릴 것인가?
  • 서비스 입장 토큰을 사용하지 않고 호출하는 API인가?

위 내용들로 생각을 해보면 현재 서비스에서 제공하는 시나리오들을 확인을 해보겠습니다.

1. 대기열 참여 및 토큰 조회

대기열은 콘서트를 임시예약 및 결제를 하기 위해서는 필수적으로 거쳐야 하는 API입니다.

 

입장인원을 2000명으로 통제하고 있지만 대기열 진입하는 요청은 무제한으로 들어올 수 있기 때문에 대기열 참여, 대기순위 및 토큰 조회 API는 부하 테스트를 진행해야 한다 생각하였습니다.

Waitng Scenario Script ( Load Test )

import http from 'k6/http';
import {check, sleep} from 'k6';
import {randomIntBetween} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';

export let options = {
    scenarios: {
        waiting_scenario: {
            executor: 'ramping-vus',
            startVUs: 100,
            stages: [
                { duration: '10s', target: 200 },  
                { duration: '10s', target: 300 },  
                { duration: '10s', target: 400 },  
                { duration: '10s', target: 500 },  
                { duration: '10s', target: 0 }  
            ],
            exec: 'waiting_scenario'
        }
    }
};

export function waiting_scenario() {

    let userId = randomIntBetween(1, 100000);
    let seriesId = randomIntBetween(1, 10000);
    waiting(userId, seriesId);
	sleep(1);
    waitingCheck(userId, seriesId);
}

function waiting(userId, seriesId) {
        
    let res = http.post(
                    'http://localhost:8080/waiting-queue/join',
                    JSON.stringify({ userId, seriesId }),
                    {
                        headers: {'Content-Type': 'application/json'},
                    }
                )
    check(res, {
        'status was 200': (r) => r.status === 200
    })
}

function waitingCheck(userId, seriesId) {
    let res = http.get('http://localhost:8080/waiting-queue/waiting-count?userId=' + userId +'&seriesId=' + seriesId);
    check(res, {
        'status was 200': (r) => r.status === 200
    })
}

성능 측정 ( Load Test ) 

 

위 성능 측정 표를 확인해 보면 1건의 실패 케이스가 나온 것을 확인할 수 있습니다.

 

하지만 이는 성능 문제가 아닌 UserId와 SeriesId로 대기열에 입장하는데 해당 아이디들이 중복으로 들어갈 경우 실패하는 것이라 올바른 상황으로 볼 수 있습니다.

 

아래 stage로 Peak Test를 진행해 보았습니다.

 

극단적으로 5000 Target으로 올렸지만 성능을 확인해 보았을 때 정상적으로 처리하고 있는 것으로 보았습니다.

export let options = {
    scenarios: {
        waiting_scenario: {
            executor: 'ramping-vus',
            startVUs: 100,
            stages: [
                { duration: '10s', target: 2000 },  
                { duration: '10s', target: 3000 },  
                { duration: '10s', target: 4000 },  
                { duration: '10s', target: 5000 },  
                { duration: '10s', target: 0 }  
            ],
            exec: 'waiting_scenario'
        }
    }
};

성능 측정( Peak Test )

2. 콘서트 결제

콘서트 결제의 경우 앞에서 했던 대기열 입장 및 서비스 입장, 토큰 발급, 콘서트 시리즈 및 좌석 조회, 포인트 충전, 임시예약, 결제 순으로 시나리오가 이루어집니다.

Concert Payment Scenario Script ( Load Test ) 

import http from 'k6/http';
import {check, sleep} from 'k6';
import {randomIntBetween, randomItem} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';

let concertId = '0006740c-54d6-11ef-8c39-0242ac110002'

export let options = {
    scenarios: {
        payment_scenario: {
            executor: 'ramping-vus',
            startVUs: 100,
            stages: [
                { duration: '10s', target: 200 },  
                { duration: '10s', target: 300 },  
                { duration: '10s', target: 400 },  
                { duration: '10s', target: 500 }
            ],
            exec: 'payment_scenario'
        }
    }
};

export function payment_scenario() {

    let userId = randomIntBetween(1, 100000);
    let seriesId = getSeriesId();

    chargePoint(userId);
    waiting(userId, seriesId);
	sleep(1);
    let tokenId = getTokenId(userId, seriesId);

    let seat = getSeat(tokenId, seriesId)
    let temporaryReservationId = temporaryReservation(tokenId, userId, seriesId, seat.seatId)
}

// ConcertSeries조회
function getSeriesId() {
    let res = http.get('http://localhost:8080/concert/series/' + concertId);
    check(res, {
        'status was 200': (r) => r.status === 200
    })
    let concertSeriesList = res.json().seriesList;
    return concertSeriesList[0].seriesId;
}

// ConcertSeries조회
function getSeat(tokenId, seriesId) {
    let res = http.get('http://localhost:8080/concert/seat/' + seriesId, {
        headers: {
            tokenId
        }
    });
    check(res, {
        'status was 200': (r) => r.status === 200
    })
    let concertSeatList = res.json().seatList;
    return randomItem(concertSeatList);
}

//포인트 충전
function chargePoint(userId) {
        
    let res = http.patch(
                    'http://localhost:8080/point/charge',
                    JSON.stringify({ userId, amount: 10000 }),
                    {
                        headers: {'Content-Type': 'application/json'},
                    }
                )
    check(res, {
        'status was 200': (r) => r.status === 200
    })
}

// 대기열 진입
function waiting(userId, seriesId) {
        
    let res = http.post(
                    'http://localhost:8080/waiting-queue/join',
                    JSON.stringify({ userId, seriesId }),
                    {
                        headers: {'Content-Type': 'application/json'},
                    }
                )
    check(res, {
        'status was 200': (r) => r.status === 200
    })
}

// 토큰아이디 조회
function getTokenId(userId, seriesId) {
    let tokenId = '';
    while(tokenId === '') {
        let res = http.get('http://localhost:8080/waiting-queue/waiting-count?userId=' + userId +'&seriesId=' + seriesId);
        check(res, {
            'status was 200': (r) => r.status === 200
        })
        let waitingData = res.json();
        tokenId = waitingData.tokenId;
        if(tokenId === '') sleep(1)
    }
    
    return tokenId;
}

// 임시예약
function temporaryReservation(tokenId, userId, seriesId, seatId) {
        
    let res = http.post(
                    'http://localhost:8080/temporary-reservation',
                    JSON.stringify({ 
                        userId,
                        concertId,
                        seriesId,
                        seatId
                    }),
                    {
                        headers: {
                            'Content-Type': 'application/json',
                            tokenId
                        },
                    }
                )
    check(res, {
        'status was 200': (r) => r.status === 200
    })

    if(res.status === 200) {
        temporaryReservationPayment(tokenId, userId, temporaryReservationId);
    }
    return res.body;
}

// 임시예약 결제
function temporaryReservationPayment(tokenId, userId, temporaryReservationId) {
    let res = http.post(
                    'http://localhost:8080/payment',
                    JSON.stringify({ 
                        userId,
                        temporaryReservationId
                    }),
                    {
                        headers: {
                            'Content-Type': 'application/json',
                            tokenId
                        },
                    }
                )
    check(res, {
        'status was 200': (r) => r.status === 200
    })
    return res.body;
}

성능 측정( Load Test )

 

테스트 결과를 보면 14%의 실패 케이스가 존재합니다.

이때 테스트 중 발생할 수 있는 케이스는 아래와 같습니다.

  • seat가 이미 결제된 경우 - 동시성 처리로 인해 발생하는 오류 정상케이스
  • 임시예약이 이미 결제된 경우 - 중복 결제처리 방지로 인해 발생하는 오류 정상케이스

이리하여 제공 중인 API들의 성능에는 문제가 없다 생각합니다.

Peak Test를 진행하였을 때는 아래와 같습니다.

성능 측정( Peak Test )

 

2000, 3000, 4000, 5000이 각 10s동안 요청이 가도록 설정하였고, 성능적으로 생각보다 느린 것을 확인할 수 있습니다.

다만 이번에도 실패 케이스는 위에서 말한 동시성 처리로 인한 의도적 오류 발생이므로 문제는 없다 생각합니다.

 

시나리오를 설정하고 테스트를 진행해 보니 따로 문제가 발생하는 점은 발견하지 못하였습니다.

 

다만, 테스트 중 성느억 문제가 있다 판단되는 API들이 존재하여 이를 개선해보고자 합니다.


문제 되는 API 개선 사항

Concert

1. Concert 목록 조회

개인적으로 가장 많은 TPS가 몰리는 API라 생각합니다.

그 이유는 대기열에 입장하지 않더라도 조회 가능한 API이고, 해당 서비스에서 가장 먼저 조회되는 데이터이기 때문입니다.

 

즉, 모든 유저들은 해당 서비스를 사용하려면 해당 API를 호출해야 하는 상황인 것입니다.

그러므로 TPS 100 이상이 나와주는 것이 적절한 성능이라 생각합니다.

기존 로직으로 조회했을 때의 성능입니다.

 

현재 성능으로 보았을 때 TPS는 1m 30s동안 1550건의 트래픽을 처리하였으므로 1550/90 = 17.2... 약 17 TPS의 성능을 가지고 있습니다.

 

이는 매우 심각한 상황으로 개선의 필요성이 많이 있다고 볼 수 있습니다.

 

여기서 성능을 개선하는 방향으로 Paging을 적용해 보았습니다.

Paiging을 적용 후 TPS를 측정해 보았을 때 1m 동안 5900건의 트래픽을 처리하였으므로 5900/60 = 98.3... 약 98 TPS의 성능이 나왔습니다. 즉, 5배 이상 되는 성능이 향상된 것을 확인해 볼 수 있었습니다.

2. ConcertSeries 목록 조회

콘서트 시리즈목록 조회의 경우 concertId로 조회가 되므로 페이징과 캐시를 제거하였음에 더 아래와 같이 5657/90 = 62.85... 로써

63 TPS의 성능을 제공할 수 있습니다.

 

3. ConcertSeat 목록 조회

ConcertSeat목록의 경우 ConcertSeriesId에 의해 적은 양의 데이터가 조회되고, Seat조회부터는 토큰을 가지고 접근하는 API이기 때문에 부하 테스트를 실행하지 않아도 된다 판단하였습니다.

 

이와 같이 서버의 장애를 대응하는 것은 매우 중요한 사항이라 볼 수 있습니다.

 

현재 상황의 경우 특정 API의 조회 성능 문제가 있었는데, 조회가 느린 게 왜 장애인가?? 할 수 있지만 서비스 자체의 성능을 저하시키는 요인으로 페이징 처리를 하지 않아 데이터가 많아질수록 성능 저하가 발행하는 엄연히 장애라고 판단할 수 있습니다.

 

 


Repository

https://github.com/KrongDev/hhplus-concert

 

GitHub - KrongDev/hhplus-concert: 콘서트 예약 서비스입니다.

콘서트 예약 서비스입니다. Contribute to KrongDev/hhplus-concert development by creating an account on GitHub.

github.com

 

항해를 마무리하면서 최종 발표자료

https://docs.google.com/presentation/d/14Tpj91PwHInYeVTrdihKlyE1dNZWB2djiMpayMHz7aY/edit?usp=sharing

728x90
728x90

728x90
728x90

Transaction의 완전 분리를 위해 @EventListener에서 @TransactionalEventListener로 변경하였습니다.

 

하지만 변경을 하고 테스트를 해보니 PointHistory데이터가 적재되지 않는 현상을 발견하였습니다...

 

우선 원인 추적을 위해 로그를 확인해 보았습니다.

위 로그를 확인해 보면 Point의 Update sql은 로그에 찍혔지만, 정장 Point_history를 Insert 하는 sql은 존재하지 않는 것을 확인할 수 있습니다.

 

왜 그럴까?

정답은 @TransactionalEventListener에 있었습니다.

 

이에 대한 내용은 여기서 확인할 수 있습니다.

내용인 즉, @TransactionalEventListener를 사용할 때 AFTER_COMMIT (the default), AFTER_ROLLBACK, or AFTER_COMPLETION 옵션을 사용하였다면 이전 트랜잭션 리소스는 살아있어 참여는 가능하나 변경내용에 대해 커밋은 할 수 없다는 내용입니다.

 

그럼 어떻게 해결할 수 있을까?

 

이전 트랜잭션에 참여하는 것이 아닌 현재 시점부터 새로운 트랜잭션을 할당하여 사용하면 되는 것입니다!

@Component
@RequiredArgsConstructor
public class PointHistoryHandler {
    //

    @TransactionalEventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void on(ChargedPoint event) {
        //
       	포인트_충전_내역_생성();
    }

    @TransactionalEventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void on(UsedPoint event) {
        //
       	포인트_사용_내역_생성();
    }
}

 

위와 같이 Transaction의 전파레벨을 Propagation.REQUIRES_NEW를 사용하면 새로 Transaction을 할당하여 정상적으로 변경사항을 Commit 또는 Rollback 할 수 있습니다.


Reference

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/event/TransactionalEventListener.html

728x90
728x90

이전에 서버의 조회 성능을 향상하기 위해 Application에서 Caching 기술을 적용해 성능을 개선시켜 보았는데요, Caching은 DB에서 데이터를 처음 조회할 때 메모리에 임시 저장하여 이후 조회 시 빠르게 데이터를 제공하는 방식으로, DB 연결 횟수를 줄여 성능을 향상하는 데 큰 도움을 주었습니다.

 

그러나 Caching은 데이터가 자주 갱신되거나, 실시간성이 중요한 경우 Caching이 오히려 성능 저하를 초래할 수도 있습니다. 이럴 때 DB 자체의 조회 성능을 직접적으로 향상하는 방법을 고려할 필요가 있습니다.

 

이번 글에서는 DB에서 조회 성능을 향상하는 대표적인 방법 중 하나인 Indexing에 대해 알아보고, 이를 현재 제작 중인 콘서트 서비스에 어떻게 적용할 수 있을지 분석 및 적용하여 성능 측정을 진행해 보도록 하겠습니다.

Index란?

데이터 베이스에서 데이터를 특정 컬럼들로 조회를 할 경우 빠르게 조회를 하기 위해 B-Tree구조로 컬럼들을 정의해 두고, 이를 통해 정렬되어 있는 클러스터들을 통해 해당 데이터를 빠르게  찾을 수 있도록 해주는 기능입니다.

 

MySQL에서는 기본 키(Primary Key, PK)를 정의하면, 해당 키에 대해 자동으로 클러스터드 인덱스(Clustered Index)가 생성됩니다. 클러스터드 인덱스는 데이터의 물리적 순서와 인덱스의 순서가 일치하도록 보장하여, 기본 키를 사용한 조회 성능을 최적화합니다.

 

인덱스의 종류
인덱스는 크게 단일 인덱스(Single Index)와 복합 인덱스(Composite Index)로 나눌 수 있습니다. 복합 인덱스는 여러 컬럼을 조합하여 생성한 인덱스로, 커버링 인덱스(Covering Index)도 그 중 하나입니다. 커버링 인덱스는 인덱스에 포함된 모든 컬럼을 통해 필요한 데이터를 직접 조회할 수 있어, 실제 데이터를 추가로 조회하지 않아도 된다는 장점이 있습니다.

 

인덱스의 단점
인덱스는 조회 성능을 크게 향상시킬 수 있지만, 단점도 있습니다. 인덱스가 설정된 컬럼의 데이터가 추가, 변경, 삭제될 때마다 인덱스가 재정의(재정렬)되는데, 이 과정은 데이터베이스의 성능 저하를 일으킬 수 있습니다.

 

따라서 무조건 Index를 거는 것보다는 Query를 분석하여 적절한 상황에 적용하는 것을 권장드립니다.

Concert에서 발생하는 Query분석

각각의 도메인에서 발생하는 Query들을 알아보고 Index를 적용하기 적절한지 또한 굳이 생성하지 않아도 괜찮은지에 대해 알아보도록 하겠습니다.

1. Concert

콘서트는 메인 도메인인 Concert와 2개의 서브도메인인 ConcertSeries와 ConcertSeat로 이루어져 있습니다.

이에 서비스에서 각각 발생하는 쿼리들을 분석해 보도록 하겠습니다.

 

Concert
콘서트 조회는 현재 전체 리스트 조회와 PK로 조회하는 Query만을 사용하고 있어 추가적인 Index생성은 불필요하다 판단하여 Index작업을 수행하지 않았습니다.

@Repository
@RequiredArgsConstructor
public class ConcertRepositoryImpl implements ConcertRepository {
    //
    private final ConcertJpaRepository concertJpaRepository;

    @Override
    public void save(Concert concert){
        //
        this.concertJpaRepository.save(new ConcertJpo(concert));
    }

    @Override
    public Concert findById(String concertId) {
        Optional<ConcertJpo> jpo = this.concertJpaRepository.findById(concertId);
        return jpo.map(ConcertJpo::toDomain).orElse(null);
    }

    @Override
    public List<Concert> findAll() {
        //
        List<ConcertJpo> jpos =  this.concertJpaRepository.findAll();
        return jpos.stream().map(ConcertJpo::toDomain).toList();
    }
}

 

ConcertSeries

콘서트 시리즈의 경우 아래와 같이 예매 가능한 콘서트 시리즈를 조회하는 Query를 날리는 상황이 존재합니다.

select csj1_0.series_id,csj1_0.concert_id,csj1_0.create_at,csj1_0.end_at,csj1_0.reserve_end_at,csj1_0.reserve_start_at,csj1_0.start_at from concert_series csj1_0 where csj1_0.concert_id=? and csj1_0.reserve_start_at<=? and csj1_0.reserve_end_at>=?

이전 Index를 걸기 전 10만 건 중 콘서트 아이디와 현재 예약가능한 콘서트를 조회한 결과입니다. 총 65ms의 속도를 자랑하는 쿼리 성능입니다.

 

Index를 적용한 Query성능입니다. 솔직히 65ms의 속도가 나와 성능이 향상될까 싶었지만 11ms의 성능이 향상되었습니다.

 

왜 그럴까?

우선 Index를 생성할 때 사용한 코드입니다.

CREATE INDEX concert_series_active_index ON concert_series(reserve_start_at,concert_id, reserve_end_at);

추후 조회 할 때 sort를 reserve_start_at으로 걸 거 같아 reserve_start_at, concert_id, reserve_end_at순으로 걸었습니다.

 

성능은 향상되었지만 현재 가져오는 속도가 너무 빨라 왜 그런지 확인을 해보았습니다.

ConcertSeries에 걸려있는 Index들입니다. 현재 저는 concert_series_active_index를 사용을 하였는데요.

reserve_start_at의 Cardinality를 보시면 1인 것을 확인하실 수 있습니다.

 

아뿔싸 제가 데이터를 적재할 때 reserve_start_at와 reserve_end_at의 값을 전부 동일하게 준 것을 기억하였습니다.

실제 오픈한 서비스에 해당 데이터들이 있다면 성능 이슈 없는 좋은 상황이겠지만, 테스트를 해야 하는 저에게는 좋지 않은 상황이었습니다.

 

다만 Index에 대한 성능 향상을 확인하였고, 동일한 데이터로 인한 성능 향상은 결국 데이터 퀄리티에 의해 영향을 받는다라는 점을 알 수 있어 좋았습니다.

 

쿼리 실행계획을 확인해 보면 Index에 걸린 row수가 꽤 된다는 것을 확인해 볼 수 있습니다.

 

ConcertSeat

가장 성능이슈가 생길 것으로 예상한 데이터입니다.

총 1000만 건으로 테스트를 진행해 보도록 하겠습니다.

3초 정도 걸리는 쿼리인 것을 확인할 수 있습니다.

쿼리 실행 계획의 경우

위와 같이 아무런 Index를 타고 있지 않아 풀스캔한 것을 확인하실 수 있습니다.

 

3초나 걸려 좌석 시트를 조회하게 된다면 그 사이 좌석이 다 예약이 될 수도 있겠죠 그래서 성능 향상은 필수인 거 같습니다.

series_id로 Index를 생성하게 될 경우 동일한 쿼리의 속도가 36ms로 주는 것을 확인해 볼 수 있습니다.

concertSeat의 경우 seat점유 상태의 값이 자주 바뀌니 해당 상태로 Index를 거는 것이 아니라면 좋은 성능을 낼 수 있다 생각합니다.


2. Payment

payment는 결제내역이므로 단건 조회의 경우 PK를 사용하여 기본 Index를 사용합니다.

목록의 경우 userId로 조회를 하기 때문에 Index가 필요한 상황입니다. 

 

아래는 100만 건으로 테스트하였습니다.

인덱스를 건 후 69ms -> 19ms로 조회 시간이 단축되는 것을 확인할 수 있습니다.

 

위 Index를 통해 충분히 성능 향상을 했다 생각합니다.


3. Point

point는 pk를  userId로 가져가고 조회 또한 userId로 조회하기 때문에 기본적으로 제공해 주는 Index를 타게 됩니다.

 

문제는 pointHistory인데, 이는  userId로 조회하기 때문에 따로 Index를 추가해주어야 합니다.

 

인덱스가 걸려있지 않은 상태로 조회하였을 때의 성능입니다. 1건의 데이터를 가져오는데 359ms의 시간이 소요되는 것을 확인할 수 있습니다.

 

userId에 Index를 걸게 될 경우 359ms -> 20ms로 확연하게 주는 것을 확인하실 수 있습니다.

쿼리 실행 계획을 확인해 보면 extra에 성능에 이슈가 될만한 use filesort 같은 것들이 명시되어있지 않고 rows가 1개이므로 성능향상에 도움이 많이 된 것 같습니다.

4. TemporaryReservation - Index생성에는 부적합

TemporaryReservation은 임시 예약 테이블로써 주기적으로 삭제가 이뤄지고 잦은 수정이 이뤄지는 곳입니다.

그로써 주기적인 수정은 Index 재정의를 유발할 수 있으므로 적합하지 않다 판단하였습니다.


5. Reservation

Reservation은 예약 테이블입니다. 예약이 완료된 건에 대하여 생성이 되며, 한번 생성되면 환불하지 않는 한 삭제는 되지 않습니다.

따라서 Index를 걸기 좋은 상황으로 판단하여 Index를 적용하였습니다.

 

Index를 userId로 걸어 생성할 경우 466ms -> 37ms로 성능이 향상되었습니다.

쿼리 플랜을 보았을 때 100건에 대한 데이터들이 존재하며 좋지 않은 상황들은 쿼리 플랜에 명시되어있지 않은 것으로 보아 유효한 Indexing이었던 것 같습니다.

 

이상으로 Concert서비스에서 사용하는 여러개의 Query들에 대해 분석하고 Indexing을 통해 성능 향상을 해보았습니다.

Indexing은 데이터베이스에 탐색을 위한 자료형을 사용하는 만큼 데이터베이스 성능에 영향을 끼친다고 볼 수 있습니다.

 

따라서  Query의 분석을 통해 정말 걸어야 하는 Query인지, Indexing 하는 컬럼들의 값들이 자주 변경되지 않는지 등을 염두하여 적재적소에 잘 활용하는 것이 중요하다 생각합니다.

728x90

'CS > Database' 카테고리의 다른 글

[ Mysql ] Mysql 기본 작업  (0) 2024.11.13
Redis는 어떤걸까?  (0) 2024.04.08
728x90

기존 서버를 이벤트 없이 작업해 본 후, Spring Framework의 Spring-Event와 Kafka를 사용하여 이벤트 주도 설계를 도입하고자 합니다. 이를 통해 이벤트의 장점을 직접 체감하고, 클라우드 환경에서의 문제점을 해결하려고 합니다.

서비스 분석 및 EDA설계

우선 현재 묶여있는 Transaction범위에 대해 분석해 보고 이를 Event를 활용하여 분리한 TO-BE를 설계해 보도록 하겠습니다.

1. 콘서트시리즈과 좌석 생성 - 트랜잭션 분리 적합

콘서트 시리즈를 생성할경우 좌석이 1:N관계로 생성이 되도록 하였습니다.

 

이때 좌석이 생성되는 케이스까지 하나의 업무 단위라 판단하였고, 이를 트랜잭션으로 묶어 구현하였습니다.

하지만, 좌석이 생성될 때 실패할 일이 있을까?? 그리고 콘서트 시리즈와 콘서트 좌석을 한 트랜잭션으로 묶일 필요가 있을까라는 고민을 하였을 때 묶일 필요는 없다고 생각하였습니다.

 

그 이유는 우선 시리즈가 생성되고 좌석이 생성될 때 좌석은 하나의 시리즈에 종속적인 관계를 가지고 있습니다.

이는 좌석을 생성할 때 실패하는 케이스는 서버의 문제로 인해 생성 중에 서버가 죽는 경우 밖에 없다고 생각하였습니다.

하여 아래와 같이 설계를 해보았습니다.

시리즈와 좌석 생성

위와 같이 하나의 트랜잭션에 묶여있던 작업을 Kafka를 활용하여 책임을 분리하여 로직의 결합을 분리하였습니다.

이로써 콘서트 시리즈는 시리즈의 생성에 대한 책임을 가지게 되고, 좌석은 좌석 생성에 대한 책임을 가져 보다 응집도 높은 코드를 작성할 수 있습니다.

 

주의
콘서트 시리즈와 콘서트 좌석은 콘서트라는 메인 도메인의 서브 도메인으로써 Event를 사용하여 동기로 작동하기보다는 동기로 같이 처리하는 것이 나을 수도 있습니다. 이에 대해 같은 도메인 그룹안에 속해 있으면 어떻게 처리할지 고민을 해보는 것이 좋을 것 같습니다. 


2. 포인트 충전 및 사용 - 트랜잭션 분리 적합

포인트를 충전하고 사용할 때마다 포인트는 History를 쌓고 있습니다.

 

이럴 때 기존 로직은 하나의 업무라 생각하여 트랜잭션을 묶어 구현을 하였습니다.

하지만, 포인트가 사용 및 충전 내역을 쌓는 거에 대한 책임을 가져야 하나?라는 생각이 들었습니다.

 

제가 생각할 때 포인트의 역할은 사용한다하면 잘 차감하고, 충전한다 하면 잘 충전하면 되는 것이지, 내역을 신경 쓸 필요는 없다 생각했습니다.

 

이러한 이유로 Point와 PointHistory를 Event를 활용하여 책임 분리를 진행하였습니다.


3. 좌석 임시 예약 - 트랜잭션 분리 적합

콘서트 좌석을 임시예약하게 될경우 TemporaryReservation이란 데이터를 생성을 하고, ConcertSeat 좌석 테이블의 점유 상태를 변경을 해주는 작업을 수행해야 합니다.

 

이때 TemporaryReservation생성과  ConcertSeat 좌석 테이블의 점유 상태를 변경하는 작업을 분리해 보는 것을 생각해 볼 수 있습니다.

 

다만 주의해야 할 점이 ConcertSeat의 점유 상태를 보고 TemporaryReservation을 생성한다는 점입니다.

이는 임시 예약을 할 때 좌석 점유 상태는 매우 중요한 정보라는 것이고, 이는 트랜잭션 분리에 적합하지 않다고 생각합니다.

 

그로 인해 EDA로 설계하였을 때 아래 그림과 같은 문제가 발생할 수 있습니다.

4. 결제 - 트랜잭션 분리 적합

결제의 경우 여러 작업들이 이뤄지는 만큼 기존 로직은 긴 트랜잭션을 유지하고 있었는데요.

이 긴 트랜잭션을 유지할 경우 DB Connect의 지속적인 연결을 통해 큰 성능 이슈가 있다 판단하였습니다.

 

또한 PaymentFacade에서 다른 도메인들을 호출하게 되면서 높은 결합도를 가진 코드가 생기게 되고, 이는 확장성에 영향을 준다고 판단하였습니다. 그리고, 앞에서 말한 긴 트랜잭션 또한 성능적 문제를 유발할 수 있다고 생각합니다.

 

다만 분리를 할 때 조심해야 하는 부분은 Point는 차감되어야 하는데, 이는 금전적인 부분이기 때문이기도 하고, 동기적으로 처리되어야 하며, 하나의 트랜잭션에 묶여있어야 좀 더 안정적인 서비스를 제공할 수 있다 판단하였습니다. 그리고, TemporaryReservation 또한 Paid라는 속성으로 인해 이벤트를 사용하여 트랜잭션을 분리하지 않는 것이 적합하다 판단하였습니다.

MSA로 분리한다면

MSA로 분리를 한다면 Domain별로 서비스를 분리해볼 수 있습니다.

 

도메인은 DDD에서 소개하는 Aggregate즉 Root Domain과 Sub Domain의 개념을 적용하였으며,

이를 통해 도메인별 연관관계를 생각하여 분리를 진행할 수 있습니다.

 

아래와 같이 책임에 따라 그리고, 기능에 따라 각 서비스는 WebClient를 활용한 동기처리, Event를 활용한 비동기 처리를 할 수 있습니다.

 

즉 서비스가 분리되어 트랜잭션이 분리되어있더라도, 동기처리를 통해 기존 방식과 동일한 기능을 활용할 수도 있습니다.

 

이번 콘서트 서비스를 분석하면서 저는 대기열을 관리하는 WaitingToken, 콘서트를 관리하는 Concert, 결제를 관리하는 Payment, 포인트를 관리하는 Point, 예약을 관리하는 Reservation으로 분리하여 관리하는 것으로 구상을 해 보았습니다.


 Kafka를 사용할 경우

Kafka는 이벤트 스트리밍 및 관리를 위한 강력한 도구로, 트랜잭션 내에서 오류가 발생하면 이벤트를 재시도할 수 있습니다. 이를 통해 시스템의 신뢰성과 가용성을 높일 수 있습니다.

 

EDA를 적용함에 있어 개인적인 생각

EDA를 적용하려면 철저한 설계가 필수적입니다. 트랜잭션을 완전히 분리하고, 데이터의 원자성을 보장할 작업과 비동기로 처리해도 무방한 작업을 명확히 구분해야 합니다.

 

SAGA 패턴, InBox/OutBox 패턴 등을 활용하여 보상 트랜잭션을 설계하는 것도 중요합니다. 다만, 트랜잭션을 분리한 만큼 보상 트랜잭션이 필요한가? 에 대한 의문은 필수적으로 가져야 한다 생각합니다.

 

무분별한 SAGA패턴, InBox/OutBox 패턴 등을 적용할 시 시스템 복잡도가 높아질 수 있으며, 만약 보상 트랜잭션의 순환이 이루어진다면 이는 매우 큰 오류로 서비스에 적용할 수 있습니다.

 

올바른 이벤트 설계를 하게된다면 도메인간 결합도를 분리하여 응집도가 높고 결합도가 낮은 코드를 작성할 수 있으며, 이는 시스템의 확장성과 유지보수성을 높일 수 있습니다.


Reference

https://www.nextree.io/spring-event/

https://www.baeldung.com/spring-events?ref=nextree.io

https://kafka.apache.org/documentation/#introduction

 

 

 

 

 

 

 

728x90
728x90

Rdb 대기열

대기열 진입 

User A가 대기열 입장을 요청한다. 
토큰을 Redy상태로 생성하고, 해당 토큰으로 대기열에 입장한다.
  
에러상황: 대기열에 이미 A의 정보가 존재할 경우 TOKEN_ALREADY_EXIST(409)를 발행한다.

순위 조회 

User A가 대기열 순위를 조회 요청한다. 

WaitingQueue를 Sort를 하여 본인의 순번이 몇 번 째인지 확인한다.
  
에러 상황: 대기열에 A의 정보가 없을 경우 WAITING_QUEUE_NOT_FOUND(401)를 발행한다. 

서버 입장 토큰 생성 ( Schedule ) 

서버는 현재 생성된 토큰 수를 조회한다. 
서버는 입장가능한 토큰 수와 현재 생성된 토큰 수를 비교하여 추가 입장 가능할 경우  WaitingQueue의 입장 가능한 토큰들의 아이디를 요청한다.
입장하는 토큰 아이디들로 토큰 상태를 입장 가능으로 변경한다.

리펙토링 Redis 대기열

대기열 진입 

User A가 대기열 입장을 요청한다. 
Redis에서는 A의 정보( Value )와 요청 시간( Score )을 저장한다. 
  
에러상황: 대기열에 이미 A의 정보가 존재할경우 WAITING_QUEUE_ALREADY_EXIST(409)를 발행한다.

순위 조회 

User A가 대기열 순위를 조회 요청한다. 
rank() 메소드를 활용하여 현재 A의 대기열 순위를 반환한다. 
  
에러 상황: 대기열에 A의 정보가 없을 경우 WAITING_QUEUE_NOT_FOUND(401)를 발행한다. 

서버 입장 토큰 생성 ( Schedule ) 

서버는 현재 생성된 토큰 수를 조회한다. 
서버는 입장가능한 토큰 수와 현재 생성된 토큰 수를 비교하여 추가 입장 가능할 경우 Redis에 요청한다. 
Redis는 range()를 통해 입장 가능한 유저 정보를 조회 후 추가 가능한 수만큼 정보를 서버로 반환한다. 
서버는 Redis에게 제공받은 유저 정보로 Token을 생성한다.

Redis로 리펙토링 한 이유

Rdb를 이용해서 구현을 먼저 하였는데, 필요한 정보들이 많다 생각하여 Repository에게 많은 케이스의 조회 메서드를 요청하였고, 이는 비즈니스 로직을 복잡하게 구현하게 되면서 발생한 상황이었습니다.

 

코드로 비교를 해보겠습니다.

public interface WaitingQueueRepository {
    Long save(WaitingQueue waitingQueue);
    void saveAll(List<WaitingQueue> waitingQueues);
    WaitingQueue findByTokenId(String tokenId);
    WaitingQueue findPrevQueue(WaitingQueueStatus status);
    List<WaitingQueue> findAllWithExpired(long expiredAt);
    List<WaitingQueue> findAllByStatusAndOffsetLimit(WaitingQueueStatus status, int limit);
    List<WaitingQueue> findAllByTokenIds(List<String> tokenIds);
    Long countByStatus(WaitingQueueStatus status);
}
public interface WaitingQueueRepository {
    boolean save(WaitingQueue waitingQueue);
    Long findWaitingQueueCount(WaitingQueue.WaitingQueueKey key);
    List<WaitingQueue.WaitingQueueKey> findWaitingQueuesByJoinCount(Long joinCount);
    boolean  existWaitingQueue (WaitingQueue.WaitingQueueKey key);
    void deleteWaitingQueues(List<WaitingQueue.WaitingQueueKey> waitingQueueKeys);
}

 

첫 번째 코드가 Rdb로 작성하였을 때, 그리고 두 번째 코드가 Redis를 사용하여 리펙토링을 진행한 코드입니다.

 

우선 딱 보기만 하더라도 크게 필요로 하는 메서드 양이 준 것이 보이고, 메서드의 용도들을 보면 용도가 명확한 메서드들만 제공하고 있습니다.

 

이렇듯 첫 번째로 구현 코드의 단순화가 가장 큰 장점이었습니다.

 

두 번째로 성능입니다.

 

Redis의 ZSet의 경우 Sorted set 자료구조를 활용함으로써 Rank 등 다양한 기능들을 빠르게 제공을 해주고 있었고, 이를 데이터를 조회하여 이전 대기열 데이터와 비교하는 비즈니스 로직등을 제거할 수 있게 되면서 필요 없던 기능들을 제거하고, 명확하게 필요한 기능들만 사용할 수 있었습니다.

성능 차이

Rdb로 구현한 대기열
Redis로 구현한 대기열

위 두 성능 측정 데이터를 보시면 대기시간 및 반복 요청시간들을 보면 시간차이가 꽤 나는 것을 확인해 보실 수 있습니다.

 

또한 동일하게 1분 동안 100명의 유저가 대기열 진입 요청을 시도한 시도인데 Redis구현한 대기열에 시도한 것을 보면 Rdb로 구현한 대기열에 시도한 횟수보다 100개가 더 많은 것을 볼 수 있습니다.

 

이는 서버에서 Redis로 구현한 대기열이 좀 더 요청을 좀더 빨리 처리하여 Rdb로 구현한 대기열보다 더 많은 트래픽을 감당한 것으로 성능은 Redis로 구현한 대기열이 더욱 좋은 것으로 보입니다. 

728x90
728x90

서버 조회 성능을 향상하는 방법으로는 Caching을 선택할 수 있는데,

Caching이 무엇이고, 어떤 상황에 도입하는 것이 적합한지 그리고 마지막으로 적용 안 했을 때와 적용했을 때의 성능 차이가 얼마나 차이 나는지 검증해 보겠습니다.

 

Caching이란?

캐싱은 자주 접근하는 데이터를 캐시라는 고속 데이터 저장소에 저장하고 접근하여 이후 동일한 데이터를 요청할 시 고속 데이터 저장소에 접근하여 데이터를 사용하는 방법입니다.

 

이는 많은 리소스를 요구하는 데이터베이스 커넥트 비용을 줄여주게 되어 서버와 데이터베이스에 가해지는 부하를 분산할 수 있다는 장점이 있습니다.

 

그럼 단점은 무엇일까??

 

우선 데이터의 일관성에 문제가 발생할 수 있습니다.

캐싱이란 것은 데이터베이스에 존재하는 데이터를 가져와 메모리에 올려두고 다음 요청 시 메모리에 존재한다면 메모리에서 해당 데이터를 가져오는 방식을 사용하게 되는데, 이때 데이터베이스에 존재하는 원본 데이터가 추가, 수정, 삭제와 같은 작업이 이루어진다면, 메모리상에 존재하는 데이터와 데이터 일관성이 깨져 유저에게 저퀄리티 즉 상한 데이터를 제공하게 됩니다.

 

이는 서비스 신뢰도의 저하를 야기할 수 있으며, 데이터의 일관적이지 않은 상황으로 인해 큰 오류로 이어질 수 있는 위험한 상황이라 생각합니다.

 

또한, 캐싱을 한다는 것 자체가 메모리에 데이터를 띄워놓고 사용한다는 의미인데, 이는 또 다른 메모리 자원을 사용하는 것이므로 무분별한 캐시 사용보다는 적합도를 추측 및 성능 검증을 통해 조심하게 사용해야 한다 생각합니다.

 

그리고, 요즘 많이 사용하는 k8s를 적용할 경우 하나의 서버를  여러 개의 Pod로 인스턴스를 할당해 운영할 수 있습니다.

이때, Local Cache를 사용하게 된다면 Pod들마다 저장하는 Local Cache의 정합성이 깨질 수 있습니다.

 

이렇듯 본인의 서버가 단일/복수의 인스턴스로 실행할 것인지, 수정이 많이 이뤄지는 데이터인지 등에 따라 제대로 사용한다면 유의미한 성능향상을 이뤄낼 수 있습니다.

 

Concert서비스 어디에 적용하는 것이 적절할까?

위에서 보았듯 여러 상황을 고려하며 어디에 적용할지 분석해 보겠습니다.

 

Local Caching VS Global Caching

Spring을 사용하시는 분이라면 Spring에서 제공해 주는 @Cacheable 어노테이션을 사용하여 캐싱을 적용해 보셨을 것입니다.

 

Spring에서 제공하는 캐싱은 AOP기반으로 작동하며, 캐시 데이터는 ConcurrentHashMap 기반의 저장소를 제공하고 있습니다. 이는 무엇인가? ConcurrentHashMap은 Multi-Thread에서 사용가능하며, ThreadSafe 합니다. 이 말은 하나의 서버 인스턴스 내에 생성된 스레드들은 해당 캐시를 공유하고 있다는 의미입니다.

 

물론 Spring에서 제공하는 어노테이션을 활용한다면 간단하게 캐싱을 적용할 수 있지만 이는 멀티 인스턴스 구조 즉 분산환경에서 Local Cache를 적용하는 것이 적합한가?? 는 다시 한번 고민해 볼 필요가 있습니다.

 

Global Cache는 이러한 관점에서 본다면 확실한 장점이 존재합니다.

외부 Storage를 사용하여 서버 인스턴스들이 해당 Storage를 접근함으로써 모든 인스턴스들은 캐시를 공유함으로써 데이터 정합성이 보장되게 됩니다.

 

또한 Local Cache의 경우 저장 하는 메모리가 JVM상에 존재하는 메모리를 활용하여 캐시가 많아질수록 서버 성능에 영향을 미칠 수 있는 반면 Global Cache는 외부에 존재함으로 서버 성능에 악영향은 없을 것으로 판단됩니다.

 

다만 따로 메모리 케쉬 서버를 사용하는 만큼 추가적인 비용과 캐싱 구현이 Local Caching보다 복잡한 점을 염두하여 어떤 캐시를 사용할 것인지 선택하면 될 거 같습니다.

 

Caching 적용 API 적합도 분석

캐싱은 보통 조회가 자주 일어나는 부분, 연산이 이뤄진 데이터를 자주 조회하는 경우에 사용한다고 생각합니다.

그럼 콘서트 서비스를 기준으로 한번 적합도를 분석해 보도록 하겠습니다.

1. 콘서트

콘서트는 콘서트 정보를 관리하는 Concert, 콘서트의 실질적인 open일 등 자세한 정보를 관리하는 ConcertSeries, 좌석정보를 관리하는 ConcertSeat 이렇게 3개로 분리하여 관리 중입니다.

 

위 3가지의 항목에 대해 적합도를 분석하겠습니다.

  • Concert
    적합도:
    판단 근거:  
    CRUD에 의거하여 생각해 보자면, 콘서트는 CUD의 빈도가 크지 않을 것이라 판단되는 도메인입니다.
    그 이유는 현재 CR API만을 제공하고 있고, 콘서트의 Title 등만을 관리할 뿐 실질적인 데이터는 ConcertSeries에서 관리하고 있기 때문입니다.

    하지만 유저가 가장 많이 접근하는 API일 것을 생각해 보면 Cache를 적용하여 조회성능을 향상하고, 생성될 때 Cache를 재업로드 하는 방식은 매우 적절할 것으로 판단됩니다.

    그러므로 Caching을 적용하고 콘서트 데이터를 생성할 때 Cache정보를 최신정보로 갱신하도록 하겠습니다. 
  • ConcertSeries
    적합도:
    판단 근거:
    ConcertSeries는 콘서트의 상세 정보, 신청 기간 등을 관리합니다.
    이는 CRUD 중 CU의 기능을 활용 중이며, 이는 자주 Update 된다면 Cache를 거는 것이 오히려 악조건이 될 수 있습니다.

    하지만, ConcertSeries의 경우 그렇게 자주 수정이 이루어질 것 같지 않아 Caching을 적용하되, 생성과 수정 시 갱신하는 방향으로 적용하겠습니다.
  • ConcertSeat
    적합도:
    판단 근거:
    콘서트 좌석의 정보를 관리하고 있는 도메인입니다.
    좌석의 예약 여부를 상태로 관리하고 있으며, 이 상태 데이터로 인해 Caching을 ConcertSeat에는 적용하지 않겠습니다.


2. 포인트

  • Point
    적합도:
    판단근거:

    포인트는 자주 변경되는 사항이 아닐 수 있지만.
    금전적인 부분을 다루는 만큼 데이터의 일관성이 무엇보다 중요하다 생각합니다.
    이로 인해 Point에는 Caching을 적용하지 않겠습니다.

3. 임시예약

  • TemporaryReservation
    적합도:
    판단근거:
    임시예약 API입니다.
    해당 도메인의 경우 5분 안에 결제가 되지 않는다면 취소되는 요구조건으로 인해 주기적으로 많은 Update가 발생할 수 있습니다. 또한 결제가 될 경우에도 상태가 변경되므로 Cache를 적용하는 것은 적합하지 않다 생각하였습니다.

4. 예약

  • Reservation
    적합도:
    판단근거:
    예약 데이터의 경우 생성하고 나면 환불하지 않는 한 수정 및 삭제가 이루어지지 않는다고 생각하여 구현하였습니다.
    즉 캐시 갱신이 이뤄지는 시점은 생성과 삭제 부분만 있다고 생각합니다. 하여 캐싱이 적용되기 합당하다 생각합니다.

성능테스트

성능 테스트 도구: K6

vus: 100

duration: 60s

콘서트 조회

Concert 1000건의 데이터를 가지고 테스트를 진행하였습니다.

캐시 미적용

캐시 적용

  캐시 없음 캐시 있음 차이
수신 데이터 730 MB (12 MB/s) 775 MB (13 MB/s) +45 MB (+1 MB/s)
송신 데이터 481 KB (7.9 KB/s) 511 KB (8.4 KB/s) +30 KB (+0.5 KB/s)
HTTP 요청 수 5532 5876 +344
반복 기간 평균=1.09초, 중간=1.01초, 최대=1.84초 평균=1.03초, 중간=1.01초, 최대=2.775초 -0.06초, 0초, +0.935초
HTTP 요청 대기 중 평균=91.12ms, 중간=12.16ms, 최대=840.43ms 평균=28.47ms, 중간=5.28ms, 최대=1.766초 -62.65ms, -6.88ms, +925.57ms

 

동일한 환경 100명의 가상 유저가 1분 동안 요청을 한경우 캐시를 사용할 경우 평균 HTTP요청 대기시간이 크게 줄고, 요청 수를 더 많이 처리한 것으로 보아 요청한 데이터를 응답받는 속도가 빠르단 것을 알 수 있었습니다.

 

콘서트 시리즈 조회

ConcertSeries데이터 1000건을 사용하여 데이터를 저장하고 있으며, ConcertId로 검색하여 조회합니다.

캐시 미적용

캐시 적용

  캐시 없음 캐시 있음 차이
수신 데이터 2.8 MB (45 kB/s) 2.9 MB (47 kB/s) +0.1 MB (+2 kB/s)
송신 데이터 748 KB (12 KB/s) 773 KB (13 KB/s) +25 KB (+1 KB/s)
HTTP 요청 수 5710 5900 +290
반복 기간 평균=1.05초, 중간=1.01초, 최대=1.38초 평균=1.02초, 중간=1.초, 최대=1.42초 -0.03초, 0.01초, +0.6초
HTTP 요청 대기 중 평균=52.88ms, 중간=12.32ms, 최대=382.91ms 평균=24ms, 중간=1.65ms, 최대=423.54ms -28.88ms, -10.67ms, +40.63ms

 

100명의 유저가 1분동안 콘서트 아이디로 시리즈를 조회하였을 때를 환경으로 설정하여 테스트해 보았습니다.

캐시를 적용하기 전보다 290건의 트래픽을 수용할 수 있는 것으로 보아 서버 성능이 향상되었음을 확인할 수 있었습니다.

 

위 2건의 캐시 적용사례를 확인해 보면 동일한 유저와 동일한 시간 동안 요청을 보냈을 때 더 많은 요청을 수용할 수 있는 것을 확인할 수 있었습니다.

 

단순 Query로 인해 성능적 차이가 크게 보이지 않았지만, 복잡한 검색기능을 도입할 시 큰 차이를 보이게 될 것이라 생각됩니다.

 

추후 다이내믹한 검색 조건을 추가하여 캐싱을 적용하였을 때 복잡한 Query에 대한 성능 분석을 추가적으로 작성하도록 하겠습니다. 


Reference

https://docs.spring.io/spring-boot/reference/io/caching.html

https://docs.spring.io/spring-data/redis/reference/redis/redis-cache.html
https://www.baeldung.com/spring-cache-tutorial

https://www.baeldung.com/spring-boot-redis-cache

 

 

728x90

+ Recent posts