728x90

개요

개발을 하거나 면접을 보게 되면 Spring Data Jpa관련 단골 질문으로 가장 많이 나오는 내용이라 생각합니다.

 

그럼 도대체 OSIV가 뭐길래 JPA에서 계속 말이 나오게 되는 걸까? 한번 알아보도록 하겠습니다.


OSIV란

OSIV란 Open Session In View의 약자로써 사실 JPA가 도입되면서 Open EntityManager In View로 개념이 확립되었으나 편의상 지금까지 OSIV라고 부르고 있습니다.

말 그대로 OSIV는 View까지 EntityManager를 열어두는 것을 의미합니다.

 

그럼 JPA에서 EntityManager이라 함은 무엇일까요?

바로 영속성 컨텍스트를 의미합니다.

 

영속성 컨텍스트는 요청이 들어오면 생성되고 사라지는 휘발성 콘텍스트입니다.

해당 콘텍스트는 기본적으로 요청이 들어오면 생성되고, 요청이 끝나면 사라지게끔 되어있는데, 이는 OSIV 설정이 기본으로 True로 설정되어 있어 가능한 일입니다.

 

만약 OSIV가 False로 설정되어 있다면 영속성 콘텍스트는 Transaction범위에 따라 생성되고 Transaction이 끝날 시 사라지게 됩니다.

 

이로써 OSIV가 어떤 설정인지 알았으니 해당 설정이 기능과 성능에 미치는 영향에 대해 알아보겠습니다.


OSIV가 미치는 영향

OSIV 설정은 아래 그림으로 한 번에 확인할 수 있습니다.

위와 같이 데이터를 생성, 수정, 삭제 기능들은 트랜잭션 범위 내에서 작업이 가능합니다.

다만 읽기 기능의 경우 영속성 콘텍스트 범위 내에 있을 경우 읽기가 가능한데,

OSIV는 영속성 콘텍스트를 view까지 늘려 트랜잭션이 아닌 요청의 생존 범위까지 늘리는 것을 의미합니다.

 

즉 생성, 수정, 삭제는 OSIV의 목적은 아니고, 읽기 범위를 늘리는 것이 주목적이라고 보시면 됩니다.

 

그럼 왜! 읽기 범위를 늘리는 것인가?

이는 Lazy Loading과 밀접한 관련이 있습니다.

 

Lazy Loading이란 지연 읽기 기능인데, 처음 데이터베이스에서 조회하는 것이 아닌 추후 해당 데이터를 사용할 때 조회하는 기능입니다.

 

해당 기능을 사용할 때, 영속성 콘텍스트의 생존범위 내에서 로딩하는지가 중요한데 이를 어디까지 허용할 것인지를 조절할 수 있다고 보시면 좋을 것 같습니다.

 

그럼 해당 기능이 왜 성능 향상에 기여할 수 있는가? 영속성 콘텍스트를 유지하는 것 또한 많은 자원을 소모하게 됩니다.

이를 View까지 유지하지 않고 트랜잭션 범위에 맞춰 종료하게 되면 OSIV가 TRUE인 상태보다 좀 더 효율적으로 자원을 사용할 수 있게 됩니다.

 

해당 옵션은 위에서 말했듯 default True인 옵션입니다.

만약 애플리케이션 성능 향상에 있어 관심이 많다면 테스트해보시길 바랍니다.


Reference

https://docs.spring.io/spring-boot/reference/data/sql.html#data.sql.jpa-and-spring-data.open-entity-manager-in-view

 

SQL Databases :: Spring Boot

Spring’s JdbcTemplate and NamedParameterJdbcTemplate classes are auto-configured, and you can autowire them directly into your own beans, as shown in the following example: import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.ste

docs.spring.io

 

 

728x90
728x90

개요

우리들에게 URL은 매일 수십 번을 적고, 복사하고, 붙여 넣는 혹은 알게 모르게 사용하고 있는 자원입니다.

 

이렇듯 우리가 여러 사이트들을 접근하기 위해서는 URL을 사용하여 접속을 해야 하는데,

어떻게 접근할 수 있는 것인지 그리고, URL자원들은 어떤 의미를 가지는지를 알아보도록 하겠습니다.


URL

우리가 URL를 통해 어떻게 사이트를 호출할 수 있는지를 알기 위해서는 URL이 뭔지 그리고 내부 요소들은 무엇이 존재하고, 어떤 의미를 가지는지를 먼저 이해하는 게 중요합니다.

 

URL은 인터넷에서 고유한 식별 주소입니다.

여기서 중요한 게 URIURL를 혼동할 수 있는데,

URI식별자이고, URL은 식별자 + 프로토콜 + 행위 등을 전부 포함한 것입니다.

 

그럼 식별자는 무엇이고, 프로토콜, 행위들이 URL의 어떤 부분인지 알아보도록 하겠습니다.


URL해부

Naver의 환율 계산하는 사이트의 주소를 해부해 보도록 하겠습니다.

https://finance.naver.com/marketindex/?tabSel=exchange#tab_section 

Schema

https://finance.naver.com/marketindex/?tabSel=exchange#tab_section 

처음 나오는 부분은 Schema입니다.

 

이는 브라우저가 요청을 처리할 시 사용할 프로토콜을 나타내게 됩니다.


Authority

https://finance.naver.com/marketindex/?tabSel=exchange#tab_section 

권한 부분입니다.

 

이는 많은 URL마다 다른 부분인데 현재 색칠되어 있는 부분 finance.naver.com은 도메인이라고 부릅니다.

 

원래 IP와 Port가 명시되어 있어야 하지만 사이트마다 다 다른 IP와 Port를 외우는 것은 너무 힘들겠죠??

그리하여 DNS라는 도메인과 IP정보를 가지고 있는 서버를 사용하여 도메인을 명시할 경우 서버에게 해당 도메인의 IP를 받아와서 호출하고자 하는 서버를 호출하는 방식으로 통신을 하고 있습니다.


Path to resource

https://finance.naver.com/marketindex/?tabSel=exchange#tab_section 

경로입니다.

이는 앞서 도메인을 통해 서버를 호출하였다면, 해당 경로 정보를 통해 원하는 콘텐츠를 제공하는 API 혹은 페이지를 접근할 수 있도록 즉 웹서버에서 해당 요청을 처리할 수 있는 위치를 특정하는 경로입니다.


Parameters

https://finance.naver.com/marketindex/?tabSel=exchange#tab_section 

매개 변수입니다.

콘텐츠를 제공받기 위해 필요한 정보를 나타내며,

시작지점은 /가 아닌?로 표기하게 되고 이 뒤에 붙는 자원들은 Key Value형태로 명시되게 됩니다.

 

여러 개를 요청할 경우 &를 붙여 동일하게 Key Value형태로 명시하게 됩니다.

 

이는 Path to resource를 통해 콘텐츠를 제공해 줄 수 있는 서버 내 위치를 특정하고,

콘텐츠를 제공받기 위해 받아야 하는 정보들을 Parameters로 제공을 받게 됩니다.


Anchor

https://finance.naver.com/marketindex/?tabSel=exchange#tab_section 

해당 페이지 내 행위입니다.

 

이는 페이지 내에서 원하는 행위를 표기하게 됩니다.

이는 무엇인가??

 

우리는 앞서 원하는 콘텐츠를 제공받기 위해 서버의 위치를 특정하고, Parameters로 원하는 정보까지 넘겨줬습니다.

하지만 우리가 받은 콘텐츠에서 어떤 행위를 의도하고 싶으면 어떻게 해야 할까요??

 

아리송한 말이죠? 우리가 네이버에 접속했다고 생각해 봅시다.

만약 우리가 네이버에만 접속하는 게 아닌, 접속했을 때 달력이 펼쳐져 있길 원한다면 어떻게 해야 할까요?

 

이렇듯 서버로부터 제공받은 콘텐츠에서 어떤 행위가 발생한 시점으로 처음부터 가기를 원한다면 Anchor를 통해 명시한다로 보면 될 것 같습니다.

 

콘텐츠를 서버로부터 받고 난 이후 처리하는 자원이기 때문에 당연히 서버에서는 사용하지 않는 자원입니다.


Reference

https://developer.mozilla.org/en-US/docs/Learn/Common_questions/Web_mechanics/What_is_a_URL

 

What is a URL? - Learn web development | MDN

A URL (Uniform Resource Locator) is the address of a unique resource on the internet. It is one of the key mechanisms used by browsers to retrieve published resources, such as HTML pages, CSS documents, images, and so on.

developer.mozilla.org

 

728x90
728x90

개요

Web Developoer로써 활동을 하고 있으면서 평소 당연하게 생각한 것들에 대해 다시 돌아보는 와중

WebServer와 WAS에 대해 설명할 수 있나 라는 생각이 들었습니다.

 

이에 기본기를 돌아보며 정리를 해보려 합니다.


WebServer란?

WebServer는 주로 HTTP통신을 처리하는 서버입니다.

 

클라이언트가 웹브라우저에서 URL를 통해 서버로 요청을 보내면, 서버는 해당 요청을 받아 일치하는 정적 콘텐츠( HTML, CSS, Javascript, image,... )를 제공하게 됩니다.

 

WebServer의 특징으로는 동일한 요청에 대해 동일한 파일을 반환합니다.

 

즉 WebServer는 웹 브라우저의 화면을 구성하는 요소들을 반환한다고 보면 좋을 것 같습니다.

주로 Apache Server, Nginx 등을 사용하고 있습니다.


WAS(Web Application Server)란?

WAS란 Web Server와 Web Container를 가지는 서버입니다.

 

주로 사용자의 요청을 받아 비즈니스 로직을 통해 처리하며, 데이터베이스 연동이 이뤄지는 작업이 있을 때 사용하는 서버로, 동적 콘텐츠를 제공받게 됩니다.

 

WebServer와 다르게 화면을 구성하는 요소들을 반환하는 것이 아닌,

화면을 구성하는데 필요한 데이터들을 연산, 데이터베이스 연동을 통한 CRUD 등의 작업들을 수행하고 반환하는 역할을 담당합니다.

주로 Tomcat 등을 사용하고 있습니다.


사용처

위에서 소개했듯이 용도에 따라 사용하는 서버가 다른데요,

페이지를 만들 때는 WebServer, 데이터를 필요로 할 때는 WAS를 사용하면 될 것으로 보입니다.

 

그럼 실제로는 어떻게 사용하고 있을 까요??

 

실제로는 두 개를 병합하여 사용하고 있습니다.

 

Tomcat의 경우 Apache Tomcat으로도 부르는데 이는 2008년부터 Tomcat에 Apache 즉 WebServer가 추가되어 있기 때문입니다.

 

그럼 Tomcat은 WAS이니 Tomcat만 쓰면 되겠네요???

제가 경험한 개발은 상황에 따라 다르게 사용하는 점이 많다는 것인데요.

 

Apache Tomcat만 사용해도 제작은 가능합니다.

 

하지만 Web Browser에서 WebServer로 요청을 보내고, WebServer에서 데이터를 얻기 위해 WAS에 요청을 보내게 됩니다.

 

이렇듯 서비스가 제대로 동작하려면 다수의 요청을 수용가능해야 하는데요,

WAS로 띄워버린다면 하나의 서버가 너무 많은 요청을 처리해야 하게 되는데 이는 좋지 않은 상황을 발생할 수 있습니다.

 

또한 WebServer는 요청에 대한 처리가 정적 콘텐츠를 제공하는 만큼 많은 자원을 필요로 하지는 않지만,

WAS의 경우 다양한 비즈니스로직 처리, DB와의 연동을 통해 데이터를 주고받으려면 어쩔 수 없이 많은 자원을 소모할 수밖에 없습니다.

 

그럼 많은 요청을 처리하기 위해서는 어떻게 해야 할까요??

 

만약 Apache Tomcat으로 모든 것을 처리할 경우 WAS만 여러 대 띄우게 될 것입니다.

 

이는 WebServer도 같이 여러 대 뜬다는 것인데요, 이는 불필요하게 서버 자원을 소모하는 요인으로 만약 이렇게 한다면 서버를 띄우는 컴퓨터 혹은 클라우드의 비용이 들게 됩니다.

 

그렇기에 WebServer와 WAS를 분리하여 가져가며 WebServer는 하나, WAS는 여러 개를 띄움으로써 많은 요청들을 효율적이게 처리하게 하는 방법을 현재 많이 사용하고 있습니다.

 

하지만 사용자가 많지 않고 WAS로 띄운 서버 성능도 별로 좋지 않아도 된다 하면,

Apache Tomcat에 한 번에 개발하는 것이 번거롭지 않고 편하겠죠.

 

즉 상황에 맞게 적용하시는 것이 좋을 것으로 보입니다.


References

https://developer.mozilla.org/en-US/docs/Learn/Common_questions/Web_mechanics/What_is_a_web_server

 

What is a web server? - Learn web development | MDN

The term web server can refer to hardware or software, or both of them working together.

developer.mozilla.org

https://www.ibm.com/topics/web-server-application-server

 

Web Server Versus Application Server | IBM

By strict definition, a web server is a common subset of an application server.

www.ibm.com

 

728x90
728x90

스펙

OS: MAC OS

VM Tool: UTM

설치 OS: Ubuntu-22.04.3

설치

UTM 설정

 1. Virtualize와 Emulate중 선택

2. 설치할 OS선택

3. 설치할 Linux image 선택 및 적용

4. 스펙 설정

5. 저장공간 설정

6. VM이름 설정 및 Setting

7. VM설치시 처음 접하는 화면 

Try or Install Ubuntu Server 선택 이후 기다리면 다시시작된다.

8. 언어 설정

9.

10. 언어설정

11.

12. 네트워크 설정

13. 프록시 설정

14. storage layout설정

15. Storage 설정

16. Ubuntu Pro 업그레이드 설정

17. SSH 설정

18. 인기많은 서버 Snaps 설정

19. Ubuntu 기본 셋팅 

기다리다 Reboot now 버튼이 생기면 Reboot now버튼을 클릭하여 리붓

20. 설치완료

이후 다시 접속 시 아래와 같이 UbuntuOs VM완성 초기 설정한 ID와 Password로 접속 가능하다.

728x90
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

Kafka란?

Kafka는 분산 메시징 시스템으로, 고가용성과 유연함을 갖춘 이벤트 스트리밍 플랫폼입니다.

대규모 데이터를 빠르고 안정적으로 처리할 수 있도록 설계되었으며, 메시지를 생산하고 소비하는 서비스들이 Kafka를 통해 데이터를 주고받습니다.

 

Kafka의 핵심 개념과 구조를 이해하기 위해 주요 구성 요소들을 하나씩 살펴보겠습니다.


 Kafka의 핵심 구성 요소

1. Broker

Kafka의 실제 서버로, 메시지를 저장하고 관리하는 역할을 합니다.

  • Producer가 보낸 메시지를 받아 저장합니다.
  • Consumer가 메시지를 읽을 때, 적절한 메시지를 찾아 제공합니다.
  • 저장된 메시지는 Offset을 기반으로 관리되며, Consumer는 이 Offset을 이용해 메시지를 순서대로 읽을 수 있습니다.

2. Partition

Kafka에서 하나의 Topic 은 여러 개의 Partition 으로 나뉘며, 각 파티션은 메시지를 저장하는 단위입니다.

  • 하나의 Topic은 여러 개의 Partition으로 구성될 수 있으며, 데이터가 분산 저장됩니다.
  • 이를 통해 병렬 처리 성능을 향상시킬 수 있습니다.

예를 들어, 하나의 Topic이 있을 때,

  • Leader Partition 1
  • Follower Partition 2, 3

이렇게 여러 개의 Partition이 존재할 수 있습니다.

3. Leader & Follower

Kafka는 고가용성을 보장하기 위해 각 Partition에 대해 Leader와 Follower를 유지합니다.

  • Leader
    • Consumer가 데이터를 읽을 때 Leader Partition에서만 읽을 수 있습니다.
    • 즉, 메시지의 주요 저장소 역할을 합니다.
  • Follower
    • Leader Partition의 데이터를 복제하여 백업 역할을 합니다.
    • 만약 Leader가 장애로 인해 다운되면, Follower 중 하나가 새로운 Leader가 됩니다.
    • 이를 통해 Kafka는 높은 가용성과 안정성을 유지할 수 있습니다.

4. Role Broker

Kafka 클러스터에는 여러 개의 Broker 가 존재하며, 각 Broker는 특정 역할을 담당합니다.

  • Controller
    • Broker들의 상태를 모니터링하며, Leader Partition을 관리하는 역할을 합니다.
    • 장애가 발생하면 새로운 Leader를 선출하여 안정성을 유지합니다.
  • Coordinator
    • ConsumerGroup을 모니터링하고, Rebalancing 작업을 수행합니다.
  • Rebalancing
    • Consumer가 추가되거나 제거될 때, 데이터 소비를 자동으로 조정하는 과정입니다.

Kafka는 이러한 자동화된 역할 분배를 통해 고가용성과 안정성을 유지합니다.

5. Zookeeper

Kafka는 메타데이터 관리를 위해 Zookeeper를 사용합니다.

  • Topic과 Partition 정보 관리
  • Broker 정보 관리
  • Kafka 클러스터 상태 감시

그러나 Kafka 2.8 버전 이후부터는 Zookeeper를 대체하는 KRaft(Kafka Raft) 구조가 도입되었습니다.


Kafka 메시지 흐름

1. Producer

  • Producer는 Kafka Broker에 메시지를 보내는 역할을 합니다.
  • 특정 Topic을 지정하여 메시지를 전송하며, Kafka는 이를 적절한 Partition에 저장합니다.

2. Consumer & ConsumerGroup

  • Consumer는 Broker에서 메시지를 읽는 역할을 합니다.
  • ConsumerGroup은 여러 Consumer로 이루어진 그룹으로, 같은 Topic의 데이터를 여러 개의 Consumer가 나누어 소비할 수 있습니다.
    • 서로 다른 ConsumerGroup은 동일한 데이터를 읽을 수도 있습니다.
    • 그러나 동일한 ConsumerGroup 내에서는 하나의 메시지를 하나의 Consumer만 소비합니다.

이를 통해 Load Balancing 이 가능하며, 대량의 데이터를 효과적으로 처리할 수 있습니다.

3. Offset

  • Offset은 메시지의 위치를 추적하는 정보입니다.
  • 각 Consumer는 자신의 Offset을 관리하여 어디까지 메시지를 읽었는지 기억할 수 있습니다.
  • 이를 활용하면, Consumer가 중단되었다가 다시 시작하더라도 중복 없이 메시지를 읽을 수 있습니다.

Kafka가 제공하는 주요 기능

Kafka는 메시징 시스템뿐만 아니라 고성능 데이터 스트리밍 플랫폼으로 활용됩니다.

  • 대용량 데이터 처리: 수백만 건의 메시지를 초당 처리할 수 있습니다.
  • 분산 구조: 여러 대의 서버로 확장 가능하여 고가용성을 보장합니다.
  • 데이터 복제: Follower Partition을 활용하여 데이터 유실을 방지합니다.
  • Rebalancing: Consumer 수가 변경될 때 자동으로 데이터를 재분배합니다.
  • Offset 기반 데이터 관리: 메시지를 중복 없이 정확하게 처리할 수 있습니다.

Kafka는 고가용성과 확장성을 갖춘 분산 메시징 시스템으로, 대용량 데이터를 안정적으로 처리할 수 있는 강력한 플랫폼입니다.

  • Producer → Kafka Broker → Consumer
  • Topic은 여러 개의 Partition으로 나뉘어 저장됨
  • Leader & Follower 구조로 장애 발생 시 자동 복구
  • ConsumerGroup을 활용해 부하를 분산하고, Rebalancing 기능으로 유연하게 데이터 소비 가능

Kafka를 사용한다면 수월하게 이벤트 기반 통신을 원활하게 구축할 수 있는 플랫폼이므로 이벤트기반 Application을 구축한다면 도입을 고민해 보면 좋을 플랫폼이라 생각합니다.

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

기존 서버를 이벤트 없이 작업해 본 후, 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

+ Recent posts