유튜브에서 FastAPI 관련한 영상을 봤는데 너무 간편해보여 찍먹을 해보려고 한다.

 

FastAPI는 API를 만들기 위한 파이썬 웹 프레임워크이다. FastAPI는 이름에 걸맞게 빠른 속도를 자랑한다. 높은 성능과 쉬운 사용성을 목표로 설계되었으며, 대규모 애플리케이션 부터 간단한 프로젝트까지 다양한 요구 사항을 만족시킬 수 있다.

더보기

API(Application Programming Interface)는 서비스의 요청과 응답에 대한 규칙을 의미하지만 보통 API라고 하면 이러한 요청과 응답을 처리하는 서비스(기능)을 의미한다.

 

주요 특징으로는

  1. 고성능 : 비동기 프로그래밍을 지원한다. 내부적으로 Starlette 라는 비동기 프레임워크를 사용한다.
  2. 쉬운 사용성 : Python의 타입 힌트를 활용하여 자동으로 요청 데이터를 검증하고 문서화한다. 이를 통해 개발자는 코드 작성과 동시에 API 문서를 쉽게 생성할 수 있다.
  3. 자동 문서화 : 자동으로 API 문서를 생성한다. Swagger UI와 ReDoc을 통해 직관적이고 사용하기 쉬운 API 문서를 제공한다.
  4. 보안 및 검증 : 데이터 검증과 보안을 간편하게 처리할 수 있는 도구를 제공한다. Pydantic을 사용하여 입력 데이터의 유효성을 검사하고, OAuth2, JWT 등 다양한 인증 방식을 지원한다.
  5. 유연성 : 플러그인과 확장이 용이하며, 다양한 데이터베이스와의 통합 및 외부 라이브러리 사용이 쉽다. 

 

간단한 예시

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    description: str = None
    price: float
    tax: float = None

@app.get("/")
async def read_root():
    return {"message": "Welcome to FastAPI"}

@app.post("/items/")
async def create_item(item: Item):
    return {"item": item}

@app.get("/items/{item_id}")
async def read_item(item_id: int):
    return {"item_id": item_id}

 

위의 코드는 FastAPI를 사용하여 기본적인 CRUD 기능을 제공하는 API를 구성하는 예시이다. 'Item' 모델을 정의하고, GET 및 POST 요청을 처리하는 엔드포인트를 설정하였다.

 

 

FastAPI 프로젝트 구조 

my_fastapi_project/
    ├── app/
    │   ├── __init__.py
    │   ├── main.py
    │   ├── core/
    │   │   ├── __init__.py
    │   │   ├── config.py
    │   │   └── security.py
    │   ├── models/
    │   │   ├── __init__.py
    │   │   └── user.py
    │   ├── schemas/
    │   │   ├── __init__.py
    │   │   └── user.py
    │   ├── crud/
    │   │   ├── __init__.py
    │   │   └── user.py
    │   ├── db/
    │   │   ├── __init__.py
    │   │   ├── base.py
    │   │   └── session.py
    │   ├── api/
    │   │   ├── __init__.py
    │   │   └── api_v1/
    │   │       ├── __init__.py
    │   │       ├── endpoints/
    │   │       │   ├── __init__.py
    │   │       │   └── users.py
    │   │       └── api.py
    │   ├── tests/
    │   │   ├── __init__.py
    │   │   └── test_user.py
    ├── alembic/
    │   └── (alembic migration files)
    ├── .env
    ├── .gitignore
    ├── requirements.txt
    ├── README.md

 

 

각 디렉토리와 파일의 역할

 

  • app/: 애플리케이션의 주요 코드가 위치하는 디렉토리입니다.
    • main.py: FastAPI 애플리케이션 인스턴스를 생성하고, 라우터를 포함한 초기 설정을 담당합니다.
    • core/: 설정, 보안, 유틸리티 함수 등을 포함합니다.
      • config.py: 애플리케이션 설정을 관리합니다.
      • security.py: 보안 관련 설정과 유틸리티를 포함합니다.
    • models/: SQLAlchemy 모델 정의를 포함합니다.
      • user.py: 사용자 모델을 정의합니다.
    • schemas/: Pydantic 모델 정의를 포함합니다.
      • user.py: 사용자 스키마를 정의합니다.
    • crud/: 데이터베이스 조작(CRUD) 함수가 포함됩니다.
      • user.py: 사용자 관련 CRUD 함수들을 포함합니다.
    • db/: 데이터베이스 설정 및 초기화 스크립트를 포함합니다.
      • base.py: Base 모델과 관련된 설정을 포함합니다.
      • session.py: 데이터베이스 세션을 관리합니다.
    • api/: 라우터 및 엔드포인트를 정의합니다.
      • api_v1/: API 버전 1과 관련된 엔드포인트를 포함합니다.
        • endpoints/: 특정 엔드포인트 모듈을 포함합니다.
          • users.py: 사용자 관련 엔드포인트를 정의합니다.
        • api.py: 버전 1의 API 라우터를 정의하고 등록합니다.
    • tests/: 테스트 코드가 포함됩니다.
      • test_user.py: 사용자 관련 테스트를 포함합니다.
  • alembic/: 데이터베이스 마이그레이션 파일을 포함합니다.
  • .env: 환경 변수를 저장하는 파일입니다.
  • .gitignore: Git에서 무시할 파일과 디렉토리를 정의합니다.
  • requirements.txt: 프로젝트에서 필요한 패키지를 명시합니다.
  • README.md: 프로젝트에 대한 설명을 포함합니다.

 

데이터를 찾는 두 가지 방법

어떤 초등학교를 방문해 '홍길동' 학생을 찾는 방법은 두 가지다. 첫째는, 1학년 1반부터 6학년 맨 마지막 반까지 모든 교실을 돌며 홍길동 학생을 찾는 것이다. 둘째는, 교무실에서 학생 명부를 조회해 홍길동 학생이 있는 교실만 찾아가는 것이다. 둘 중 어느 쪽이 빠를까? 홍길동 학생이 많다면 전자가 빠르고, 몇 안되면 후자가 빠르다.

 

데이터베이스 테이블에서 데이터를 찾는 방법도 아래 두 가지다. 수십 년에 걸쳐 DBMS가 발전해 왔는데도 이 두 방법에서 크게 벗어나지 못하고 있다.

  • 테이블 전체를 스캔한다.
  • 인덱스를 이용한다.

 

 

인덱스 튜닝의 두 가지 핵심요소

인덱스는 큰 테이블에서 소량 데이터를 검색할 때 사용한다. 온라인 트랜잭션 처리 시스템에서는 소량 데이터를 주로 검색하므로 인덱스 튜닝이 무엇보다 중요하다. 

세부적인 인덱스 튜닝 방법으로 여러 가지가 있지만, 핵심요소는 크게 두 가지로 나뉜다. 첫번째는 인덱스 스캔 과정에서 발생하는 비효율을 줄이는 것이다. 즉 '인덱스 스캔 효율화 튜닝'이다.

 

예를 들어, 학생명부에서 키가 170cm ~ 173cm인 홍길동 학생을 찾는 경우로 예를 들어보자. 학생명부를 이름과 키순으로 정렬해 두었다면, 소량만 스캔하면 된다.

이름 학년-반-번호
강수지 171 4학년 3반 37번
김철수 180 3학년 2반 13번
...    
이영희 172 6학년 4반 19번
...    
홍길동 168 2학년 6반 24번
홍길동 170 5학년 1반 16번
홍길동 173 1학년 5반 15번
....    

 

반면, 학생명부를 시력과 이름순으로 정렬해 두었다면, 똑같이 두 명을 찾는데도 많은 양을 스캔해야 한다.

 

시력 이름 학년-반-번호
168 홍길동  
....    
170 홍길동  
171 강수지  
172 이영희  
173 홍길동  
...    
180 김철수  

 

인덱스 튜닝의 두 번째 핵심요소는 테이블 액세스 횟수를 줄이는 것이다. 인덱스 스캔 후 테이블 레코드를 액세스할 때 랜덤 I/O 방식을 사용하므로 이를 '랜덤 액세스 최소화 튜닝'이라고 한다.

 

인덱스 스캔 효율화 튜닝과 랜덤 액세스 최소화 튜닝 둘 다  중요하지만, 더 중요한 하나를 고른다면 랜덤 액세스 최소화 튜닝이다. 성능에 미치는 영향이 크기 때문이다. SQL 튜닝은 랜덤 I/O와의 전쟁이다.

 

 

 

인덱스 구조

인덱스는 대용량 테이블에서 필요한 데이터만 빠르게 효율적으로 액세스하기 위해 사용하는 오브젝트다. 모든 책 뒤쪽에 있는 색인과 같은 역할을 한다. 데이터베스에서 인덱스 없이 데이터를 검색하려면, 테이블을 처음부터 끝까지 모두 읽어야 한다. 반면, 인덱스를 이용하면 일부만 읽고 멈출 수 있다. 즉, 범위 스캔(Range Scan)이 가능하다. 범위 스캔이 가능한 이유는 인덱스가 정렬돼 있기 때문이다. 

DBMS는 일반적으로 B*Tree 인덱스를 사용한다. 나무(Tree)를 거꾸로 뒤집은 모양이여서 뿌리(Root)가 위쪽에 있고, 가지(Branch)를 거쳐 맨 아래에 잎사귀(Leaf)가 있다.

 

인덱스 구조

 

루트와 브랜치 블록에 있는 각 레코드는 하위 블록에 대한 주소값을 갖는다. 키값은 하위 블록에 저장된 키값의 범위를 나타낸다. 

  • ROWID = 데이터 블록 주소 + 로우 번호
  • 데이터 블록 주소 = 데이터 파일 번호 + 블록 번호
  • 블록 번호 : 데이터파일 내에서 부여한 상대적  순번
  • 로우 번호 : 블록 내 순번 

인덱스 탐색 과정은 수직적 탐색과 수평적 탐색으로 나눌 수 있다.

  • 수직적 탐색 : 인덱스 스캔 시작지점을 찾는 과정
  • 수평적 탐색 : 데이터를 찾는 과정

 

 

인덱스 수직적 탐색 

정렬된 인덱스 레코드 중 조건을 만족하는 첫 번째 레코드를 찾는 과정이다. 즉, 인덱스 스캔 시작지점을 찾는 과정이다.

인덱스 수직적 탐색은 루트(Root) 블록에서부터 시작한다. 루트를 포함해 브랜치(Branch) 블록에 저장된 각 인덱스 레코드는 하위 블록에 대한 주소값을 갖는다. 루트에서 시작해 리프(Leaf) 블록까지 수직적 탐색이 가능한 이유다.

수직적 탐색 과정에 찾고자 하는 값보다 크거나 같은 값을 만나면, 바로 직전 레코드가 가리키는 하위 블록으로 이동한다.

 

수직적 탐색은 '조건을 만족하는 레코드'를 찾는 과정이 아니라 '조건을 만족하는 첫 번째 레코드'를 찾는 과정임을 반드시 기억해야 한다.

 

 

인덱스 수평적 탐색

수직적 탐색을 통해 스캔 시작점을 찾았으면, 찾고자 하는 데이터가 더 안 나타날 때까지 인덱스 리프 블록을 수평적으로 스캔한다. 인덱스에서 본격적으로 데이터를 찾는 과정이다.

인덱스 리프 블록끼리는 서로 앞뒤 블록에 대한 주소값을 갖는다. 즉, 양방향 연결 리스트(double linked list) 구조다. 좌에서 우로, 또는 우에서 좌로 수평적 탐색이 가능한 이유다. 

인덱스를 수평적으로 탐색하는 이유는 첫째, 조건절을 만족하는 데이터를 모두 찾기 위해서고 둘째, ROWID를 얻기 위해서다. 

 

'Back-End > DB' 카테고리의 다른 글

오라클 MERGE INTO 구문과 INACTIVE  (1) 2024.10.18
DB Connection is not associated  (1) 2024.09.10
캐시 탐색 메커니즘  (2) 2024.07.30
데이터 저장 구조 및 I/O 메커니즘  (2) 2024.07.29
SQL 공유 및 재사용  (0) 2024.07.26

Direct Path I/O를 제외한 모든 블록 I/O는 메모리 버퍼캐시를 경유한다. 

  • 인덱스 루트 블록을 읽을 때
  • 인덱스 루트 블록에서 얻은 주소 정보로 브랜치 블록을 읽을 때
  • 인덱스 브랜치 블록에서 얻은 주소 정보로 리프 블록을 읽을 때
  • 인덱스 리프 블록에서 얻은 주소 정보로 테이블 블록을 읽을 때
  • 테이블 블록을 Full Scan 할 때

버퍼캐시 해시 구조

 

DBMS는 위와 같이 버퍼캐시를 해시 구조로 관리한다.

예를 들어, 버퍼캐시에서 20번 블록을 찾고자 하다고 가정해보자. 블록 번호를 5로 나누면 나머지가 0이다. 이 블록이 캐싱돼 있다면 버퍼 헤더가 첫 번째 해시 체인에 연결돼 있을 것이다. 이 블록이 캐싱돼 있다면 버퍼 헤더가 첫 번째 해시 체인에 연결돼 있을 것이므로 찾을 때 항상 첫 번째 해시 체인만 탐색하면 된다.

 

버퍼캐시에서 블록을 찾을 때 이처럼 해시 알고리즘으로 버퍼 헤더를 찾고, 거기서 얻은 포인터(Pointer)로 버퍼 블록을 액세스하는 방식을 사용한다.

  • 같은 입력 값은 항상 동일한 해시 체인(=버킷)에 연결됨
  • 다른 입력 값이 동일한 해시 체인에 연결될 수 있음
  • 해시 체인 내에서는 정렬이 보장되지 않음

버퍼캐시는 SGA 구성요소이므로 버퍼캐시에 캐싱된 버퍼블록은 모두 공유자원이다. 공유자원은 말 그대로 모두에게 권한이 있기 때문에 누구나 접근할 수 있다.

두 개 이상의 프로세스가 동시에 접근하려고 할 때는 문제가 발생한다. 블록 정합성에 문제가 생길 수 있기 때문이다. 따라서 내부에서는 한 프로세스씩 순차적으로 접근하도록 구현해야 하며, 이를 위해 직렬화(serialization) 메커니즘이 필요하다. 

'Back-End > DB' 카테고리의 다른 글

DB Connection is not associated  (1) 2024.09.10
인덱스 구조 및 탐색  (0) 2024.08.05
데이터 저장 구조 및 I/O 메커니즘  (2) 2024.07.29
SQL 공유 및 재사용  (0) 2024.07.26
SQL 파싱과 최적화  (3) 2024.07.25

SQL이 느린 이유

SQL이 느린 이유는 대부분 I/O 때문이다. 구체적으로 디스크 I/O 때문이다.

 

그렇다면, I/O란 무엇일까?

 

'I/O = 잠(SLEEP)'이라고 생각하면 쉽다. OS 또는 I/O 서브시스템이 I/O를 처리하는 동안 프로세스는 잠을 자기 때문이다. 프로세스가 일하지 않고 잠을 자는 이유는 여러가지가 있지만, I/O가 가장 대표적이고 절대 비중을 차지한다. 

 

프로세스(Process)는 '실행 중인 프로그램'이며, 생명주기를 갖는다. 즉, 생성(new) 이후 종료(terminated) 전까지 준비(ready)와 실행(running)과 대기(waiting) 상태를 반복한다. 실행 중인 프로세스는 interrupt에 의해 수시로 실행 준비 상태(Runnable Queue)로 전환했다가 다시 실행 상태로 전환한다. 여러 프로세스가 하나의 CPU를 공유할 수 있지만, 특정 순간에는 하나의 프로세스만 CPU를 사용할 수 있기 때문에 이런 메커니즘이 필요하다. 

프로세스 생명주기

 

interrupt 없이 열심히 일하던 프로세스도 디스크에서 데이터를 읽어야 할 땐 CPU를 OS에 반환하고 잠시 수면(waiting) 상태에서 I/O가 완료되기를 기다린다. 정해진 OS 함수를 호출(I/O Call)하고 CPU를 반환한 채 알람을 설정하고 대기 큐(Wait Queue)에서 잠을 자는 것이다. 열심히 일해야 할 프로세스가 한가하게 잠을 자고 있으니 I/O가 많으면 성능이 느릴 수 밖에 없다. 

 

 

 

데이터베이스 저장 구조

데이터를 저장하려면 먼저 테이블스페이스를 생성해야 한다. 테이블스페이스는 세그먼트를 담는 콘테이너로서, 여러 개의 데이터파일(디스크 상의 물리적인 OS 파일)로 구성된다.

 

테이블스페이스

 

테이블스페이스를 생성했으면 위와 같이 세그먼트를 생성한다. 세그먼트는 테이블, 인덱스처럼 데이터 저장공간이 필요한 오브젝트다. 테이블, 인덱스를 생성할 때 데이터를 어떤 테이블스페이스에 저장할지를 지정한다.

 

세그먼트는 여러 익스텐트로 구성된다. 파티션 구조가 아니라면 테이블도 하나의 세그먼트고, 인덱스도 하나의 세그먼트다. 테이블 또는 인덱스가 파티션 구조라면, 각 파티션이 하나의 세그먼트가 된다. LOB 컬럼은 그 자체가 하나의 세그먼트를 구성하므로 자신이 속한 테이블과 다른 별도 공간에 값을 저장한다.

 

익스텐트공간을 확장하는 단위이다. 테이블이나 인덱스에 데이터를 입력하다가 공간이 부족해지면 해당 오브젝트가 속한 테이블스페이스로부터 익스텐트를 추가로 할당받는다. 익스텐트는 연속된 블록들의 집합이기도 하다. 

익스텐트 단위로 공간을 확장하지만, 사용자가 입력한 레코드를 실제로 저장하는 공간은 데이터 블록이다. 한 블록은 하나의 테이블만 독점한다. 즉, 한 블록에 저장된 레코드는 모두 같은 테이블 레코드다.

 

세그먼트 공간이 부족해지면 테이블스페이스로부터 익스텐트를 추가로 할당받는다고 했는데, 세그먼트에 할당된 모든 익스텐트가 같은 데이터파일에 위치하지 않을 수 있다. 

 

테이블스페이스 익스텐트

 

익스텐트  내 블록은 서로 연속된 공간이지만, 익스텐트끼리는 연속된 공간이 아니라는 사실을 위의 그림을 통해 알 수 있다. 

-- 오라클에서 세그먼트에 할당된 익스텐트 목록 조회 방법
SQL >
select segment_type, tablespace_name, extent_id, file_id, block_id, blocks
from dba_extents
and segment_name = 'MY_SEGMENT'
order by extent_id;

 

더보기

DBA(Data Block Address)

모든 데이터 블록은 디스크 상에서 몇 번 데이터파일의 몇 번째 블록인지를 나타내는 자신만의 고유 주소값을 갖는다. 이 주소값을'DBA(Data Block Address)'라고 부른다. 데이터를 읽고 쓰는 단위가 블록이므로 데이터를 읽으려면 먼저 DBA부터 확인해야 한다.

인덱스를 이용해 테이블 레코드를 읽을 때는 인덱스 ROWID를 이용해야한다. ROWID는 DBA + 로우 번호(블록 내 순번)로 구성되므로 이를 분해하면 읽어야 할 테이블 레코드가 저장된 DBA를 알 수 있다. 

테이블을 스캔할 때는 테이블 세그먼트 헤더에 저장된 익스텐트 맵을 이용한다. 익스텐트 맵을 통해 각 익스텐트의 첫 번째 블록 DBA를 알 수 있다. 

 

블록, 익스텐트, 세그먼트, 테이블스페이스, 데이터파일을 정의하면 다음과 같다.

  • 블록 : 데이터를 읽고 쓰는 단위
  • 익스텐트 : 공간을 확장하는 단위, 연속된 블록 집합
  • 세그먼트 : 데이터 저장공간이 필요한 오브젝트(테이블, 인덱스, 파티, LOB 등)
  • 테이블스페이스 : 세그먼트를 담는 컨테이너
  • 데이터파일 : 디스크 상의 물리적인 OS 파일

테이블스페이스 ERD

 

 

블록 단위 I/O

데이터 I/O 단위가 블록이므로 특정 레코드 하나를 읽고 싶어도 해당 블록을 통째로 읽는다. 심지어 1Byte짜리 컬럼 하나만 읽고 싶어도 블록을 통째로 읽는다. 오라클은 기본적으로 8KB 크기의 블록을 사용하므로 1Byte를 읽기 위해 8KB를 읽는 셈이다. 

-- 오라클 데이터베이스의 블록 사이즈 확인 방법.
SQL > show parameter block_size

 

테이블뿐만 아니라 인덱스도 블록 단위로 데이터를 읽고 쓴다. 

 

 

 

시퀀셜 액세스 vs 랜덤 액세스

테이블 또는 인덱스 블록을 액세스하는(=읽는) 방식으로는 시퀀셜 엑세스와 랜덤 액세스, 두 가지가 있다.

 

첫째, 시퀀셜(Sequential) 액세스는 논리적 또는 물리적으로 연결된 순서에 따라 차례대로 블록을 읽는 방식이다. 인덱스 리프 블록은 앞뒤를 가리키는 주소값을 통해 논리적으로 서로 연결돼 있다. 이 주소 값에 따라 앞 또는 뒤로 순차적으로 스캔하는 방식이 시퀀셜 액세스다.

 

테이블 블록 간에는 서로 논리적인 연결고리를 가지고 있지 않다. 그럼, 테이블은 어떻게 시퀀셜 방식으로 엑세스할까?

 

오라클은 세그먼트에 할당된 익스텐트 목록을 세그먼트 헤더에 맵(map)으로 관리한다. 익스텐트 맵은 각 익스텐트의 첫 번째 블록 주소 값을 갖는다. 

읽어야 할 익스텐트 목록을 익스텐트 맵에서 얻고, 각 익스텐트의 첫 번째 블록 뒤에 연속해서 저장된 블록은 순서대로 읽으면, 그것이 곧 Full Table Scan이다. 

 

둘때, 랜덤(Random) 액세스는 논리적, 물리적인 순서를 따르지 않고, 레코드 하나를 읽기 위해 한 블록씩 접근(=touch)하는 방식이다. 

 

 

 

논리적 I/O vs 물리적 I/O

DB버퍼캐시

디스크 I/O가 SQL 성능을 결정한다. SQL을 수행하는 과정에 계속해서 데이터 블록을 읽는데, 자주 읽는 블록을 매번 디스크에서 읽는 것은 매우 비효율적이다. 모든 DBMS에 데이터 캐싱 메커니즘이 필수인 이유다.

SGA

 

데이터를 캐싱하는 'DB버퍼캐시'도 SGA의 가장 중요한 구성요소 중 하나다. 라이브러리 캐시가 SQL과 실행계획, DB 저장형 함수/프로시저 등을 캐싱하는 '코드 캐시'라고 한다면, DB버퍼캐시는 '데이터 캐시' 라고 할 수 있다.

 

디스크에서 어렵게 읽은 데이터 블록을 캐싱해 둠으로써 같은 블록에 대한 반복적인 I/O Call을 줄이는 데 목적이 있다.

DB Buffer Cache

 

위 그림처럼 서버 프로세스와 데이터파일 사이에 버퍼캐시가 있으므로 데이터 블록을 읽을 땐 항상 버퍼캐시부터 탐색한다. 운 좋게 캐시에서 블록을 찾는다면 바쁜 시간에 프로레스가 잠(I/O Call)을 자지 않아도 된다. 버퍼캐시는 공유메모리 영역이므로 같은 블록을 읽는 다른 프로세스도 이득을 본다.

-- 오라클 SQL*Plus에서 버퍼캐시 확인.
SQL > show spa

 

 

 

논리적 I/O vs 물리적 I/O

논리적 블록 I/O는 SQL을 처리하는 과정에 발생한 총 블록 I/O를 말한다. 

 

위 그림의 좌측처럼 메모리상의 버퍼 캐시를 경유하므로 메모리 I/O가 곧 논리적 I/O라고 생각해도 무방하다.

 

물리적 블록 I/O는 디스크에서 발생한 총 블록 I/O를 말한다. SQL 처리 도중 읽어야 할 블록을 버퍼캐시에서 찾지 못할 때만 디스크를 액세스하므로 논리적 블록 I/O 중 일부를 물리적으로 I/O 한다.

 

메모리 I/O는 전기적 신호인 데 반해, 디스크 I/O는 액세스 암을 통해 물리적 작용이 일어나므로 메모리 I/O에 비해 상당히 느리다. 보통 10,000배쯤 느리다.

 

데이터베이스 세계에서 논리적 일량과 물리적 일량을 정의해 보자. SQL을 수행하려면 데이터가 담긴 블록을 읽어야 한다. SQL이 참조하는 테이블에 데이터를 입력하거나 삭제하지 않는 상황에서 조건절에 같은 변수 값을 입력하면, 아무리 여러 번 실행해도 매번 읽는 블록 수는 같다. SQL을 수행하면서 읽은 총 블록 I/O가 논리적 I/O다.

 

DB 버퍼캐시에서 블록을 찾지 못해 디스크에서 읽은 블록 I/O가 물리적 I/O다. 데이터 입력이나 삭제가 없어도 물리적 I/O는 SQL을 실행할 때마다 다르다. 연속해서 실행하면 DB 버퍼캐시에서 해당 테이블 블록의 점유율이 점점 높아지기 때문이다. 

 

 

버퍼캐시 히트율

 

 

Single Block I/O vs Multiblock I/O

메모리 캐시가 클수록 좋지만, 데이터를 모두 캐시에 적재할 수는 없다. 비용적인 한계, 기술적인 한계 때문에 전체 데이터 중 일부만 캐시에 적재해서 읽을 수 있다.

캐시에서 찾지 못한 데이터 블록은 I/O Call을 통해 디스크에서 DB 버퍼캐시로 적재하고서 읽는다. I/O Call을 할 때, 한 번에 한 블록씩 요청하기도 하고, 여러 블록씩 요청하기도 한다. 

한 번에 한 블록씩 요청해서 메모리에 적재하는 방식을 'Single Block I/O'라고 한다. 많은 벽돌을 실어 나를 때 손수레를 이용하는 것처럼 한 번에 여러 블록씩 요청해서 메모리에 적재하는 방식을 'Multiblock I/O'라고 한다.

 

인덱스를 이용할 때는 기본적으로 인덱스와 테이블 블록 모두 Single Block I/O 방식을 사용한다. 

  • 인덱스 루트 블록을 읽을 때
  • 인덱스 루트 블록에서 얻은 주소 정보로 브랜치 블록을 읽을 때
  • 인덱스 브랜치 블록에서 얻은 주소 정보로 리프 블록을 읽을 때
  • 인덱스 리프 블록에서 얻은 주소 정보로 테이블 블록을 읽을 때

반대로, 많은 데이터 블록을 읽을 때는 Multiblock I/O 방식이 효율적이다. 그래서 인덱스를 이용하지 않고 테이블 전체를 스캔할 때 이 방식을 사용한다. 테이블이 클수록 Multiblock I/O 단위도 크면 좋다. 프로세스가 잠자는 횟수를 줄여주는 데 이유가 있다.

 

 

Table Full Scan vs Index Range Scan

테이블에 저장된 데이터를 읽는 방식은 두 가지다.

Table Full Scan은 말 그대로 테이블에 속한 블록 '전체'를 읽어서 사용자가 원하는 데이터를 찾는 방식이다. 인덱스를 이용한 테이블 액세스는 인덱스에서 '일정량'을 스캔하면서 얻은 ROWID로 테이블 레코드를 찾아가는 방식이다. ROWID는 테이블 레코드가 디스크 상에 어디 저장됐는지를 가리키는 위치 정보다.

 

인덱스를 이용하는데 성능이 느린 경우는 왜그럴까?

 

시퀀셜 액세스와 랜덤 액세스, Single Block I/O와 Multiblock I/O 등등 I/O 메커니즘 관점에서 Table Full Scan과 Index Range Scan의 본질을 알아보자. 

 

Table Full Scan은 시퀀셜 액세스와 Multiblock I/O 방식으로 디스크 블록을 읽는다. 한 블록에 속한 모든 레코드를 한 번에 읽어 들이고, 캐시에서 못 찾으면 '한 번의 수면(I/O Call)을 통해 인접한 수십~수백 개 블록을 한꺼번에 I/O하는 메커니즘'이다. 이 방식을 사용하는 SQL은 스토리지 스캔 성능이 좋아지는 만큼 성능도 빨라진다.

 

큰 테이블에서 소량 데이터를 검색할때는 반드시 인덱스를 이용해야 한다.

Index Range Scan을 통한 테이블 액세스는 랜덤 액세스와 Single Block I/O 방식으로 디스크 블록을 읽는다. 캐시에서 블록을 못 찾으면, '레코드를 찾기 위해 매번 잠을 자는 I/O 메커니즘'이다. 따라서 많은 데이터를 읽을 때는 Table Full Scan보다 불리하다. 읽을 데이터가 일정량을 넘으면 인덱스보다 Table Full Scan이 유리하다.

'Back-End > DB' 카테고리의 다른 글

인덱스 구조 및 탐색  (0) 2024.08.05
캐시 탐색 메커니즘  (2) 2024.07.30
SQL 공유 및 재사용  (0) 2024.07.26
SQL 파싱과 최적화  (3) 2024.07.25
Lock 과 트랜잭션 동시성 제어 (Oracle)  (3) 2024.07.23

소프트 파싱 vs 하드 파싱

SQL 파싱, 최적화, 로우 소스 생성 과정을 거쳐 생성한 내부 프로시저를 반복 재사용할 수 있도록 캐싱해 두는 메모리 공간을 '라이브러리 캐시(Libray Cache)'라고 한다. 라이브러리 캐시는 SGA 구성요소다. SGA(System Global Area)는 서버 프로세스와 백그라운드 프로세스가 공통으로 액세스하는 데이터와 제어 구조를 캐싱하는 메모리 공간이다.

SGA

 

사용자가 SQL문을 전달하면 DBMS는 SQL을 파싱한 후 해당 SQL이 라이브러리 캐시에 존재하는지부터 확인한다. 캐시에서 찾으면 곧바로 실행 단계로 넘어가지만, 찾지 못하면 최적화 단계를 거친다. SQL을 캐시에서 찾아 곧바로 실행단계로 넘어가는 것을 '소프트 파싱(Soft Parsing)'이라 하고, 찾는 데 실패해 최적화 및 로우 소스 생성 단계까지 모두 거치는 것을 '하드 파싱(Hard Parsing)'이라고 한다.

 

SQL 최적화 과정을 왜 하드(Hard)할까?

 

옵티마이저가 SQL을 최적화할 때 데이터베이스 사용자들이 보통 생각하는 것보다 훨씬 많은 일을 수행한다. 다섯 개 테이블을 조인하는 쿼리문 하나를 최적화하는 데도 무수히 많은 경우의 수가 존대한다. 조인 순서만 고려해도 120가지다. 여기서 NL 조인, 소트 머지 조인, 해시 조인 등 다양한 조인 방식이 있다. 테이블 전체를 스캔할지, 인데스를 이용할지 결정해야 하고, 인덱스 스캔에도 Index Range Scan, Index Unique Scan, Index Full Scan 등 다양한 방식이 제공된다. 이렇게 SQL 옵티마이저는 순식간에 엄청나게 많은 연산을 한다. 그 과정에서 옵티마이저가 사용하는 정보는 다음과 같다.

  • 테이블, 컬럼, 인덱스 구조에 관한 기본 정보
  • 오브젝트 통계 : 테이블 통계, 인덱스 통계. (히스토그램을 포함한) 컬럼 통계
  • 시스템 통계 : CPU 속도, Single Block I/O 속도, Multiblock I/O 속도 등
  • 옵티마이저 관련 파라미터

이렇게 어려운 작업을 거쳐 생성한 내부 프로시저를 한 번만 사용하고 버린다면 엄청난 비효율일 것이다. 라이브러리 캐시가 필요한 이유가 바로 여기에 있다.

'Back-End > DB' 카테고리의 다른 글

캐시 탐색 메커니즘  (2) 2024.07.30
데이터 저장 구조 및 I/O 메커니즘  (2) 2024.07.29
SQL 파싱과 최적화  (3) 2024.07.25
Lock 과 트랜잭션 동시성 제어 (Oracle)  (3) 2024.07.23
인덱스의 기본 (2)  (0) 2023.10.12

SQL을 실행하기 전 최적화 과정을 세분화해보자

 

1. SQL 파싱

사용자로부터 SQL을 전달받으면 가장 먼저 SQL 파서(Parser)가 파싱을 진행한다. SQL 파싱을 요약하면 다음과 같다.

  • 파싱 트리 생성 : SQL문을 이루는 개별 구성요소를 분석해서 파싱 트리 생성
  • Syntax 체크 : 문법적 오류가 없는지 확인. 예를 들어, 사용할 수 없는 키워드를 사용했거나 순서가 바르지 않거나 누락된 키워드가 있는지 확인한다.
  • Semantic 체크 : 의미상 오류가 없는지 확인. 예를 들어, 존재하지 않는 테이블 또는 컬럼을 사용했는지, 사용한 오브젝트에 대한 권한이 있는지 확인한다.

 

2. SQL 최적화

그다음 단계가 SQL 최적화이고, 옵티마이저(Optimizer)가 그 역할을 맡는다. SQL 옵티마이저는 미리 수집한 시스템 및 오브젝트 통계정보를 바탕으로 다양한 실행경로를 생성해서 비교한 후 가장 효율적인 하나를 선택한다. 데이터베이스 성능을 결정하는 가장 핵심적인 엔진이다.

 

 

3. 로우 소스 생성

SQL 옵티마이저가 선택한 실행경로를 실제 실행 가능한 코드 또는 프로시저 형태로 포맷팅 하는 단계다. 로우 소스 생성시(Row-Source Generator)가 그 역할을 맡는다.

 

 

 

SQL 옵티마이저

SQL 옵티마이저는 사용자가 원하는 작업을 가장 효율적으로 수행할 수 있는 최적의 데이터 액세스 경로를 선택해 주는 DBMS의 핵심 엔진이다. 옵티마이저의 최적화 단계를 요약하면 다음과 같다.

  1. 사용자로부터 전달받은 쿼리를 수행하는 데 후보군이 될만한 실행계획들을 찾아낸다.
  2. 데이터 딕셔너리(Data Dictionary)에 미리 수집해 둔 오브젝트 통계 및 시스템 통계정보를 이용해 각 실행계획의 예상비용을 산정한다.
  3. 최저 비용을 나타내는 실행계획을 선택한다.

 

 

 

 

실행계획과 비용

SQL 옵티마이저는 자동차 네비게이션과 여러모로 흡사하다. 경로 요약이나 모의 주행 같은 기능이 그렇다. 

DBMS에도 SQL 실행경로 미리보기 기능이 있다. 실행계획(Execution Plan)이 바로 그것이다. SQL 옵티마이저가 생성한 처리절차를 사용자가 확인할 수 있게 아래와 같이 트리 구조로 표현한 것이 실행계획이다.

실행계획

 

미리보기 기능을 통해 자신이 작성한 SQL이 테이블을 스캔하는지 인덱스를 스캔하는지, 인덱스를 스캔한다면 어떤 인덱스인지를  확인할 수 있고, 예상과 다른 방식으로 처리된다면 실행경로를 변경할 수 있다.

 

옵티마이저가 특정 실행계획을 선택하는 근거는 무엇일까?

 

비용을 통해 선택을 하게 된다. 비용(Cost)은 쿼리를 수행하는 동안 발생할 것으로 예상하는 I/O 횟수 또는 예상 소요시간을 표현한 값이다.

 

하지만 SQL 실행계획에 표시되는 Cost는 어디까지나 예상치다. 실행경로를 선택하기 위해 옵티마이저가 여러 통계정보를 활용해서 계산해 낸 값이다. 실측치가 아니므로 실제 수행할 때 발생하는 I/O 또는 시간과 많은 차이가 난다.

 

 

 

옵티마이저 힌트

힌트 사용법은 아래와 같다. 주석 기호에 '+'를 붙이면 된다.

SELECT /*+ INDEX(A 고객_PK) */
  고객명, 연락처, 주소, 가입일시
 FROM 고객 A
 WHERE 고객ID = :cust_id;

 

주의사항

/*+ INDEX(A A_X01) INDEX(B, B_X03) */ 	-> 모두 유효
/*+ INDEX(C), FULL(D) */ 				-> 첫 번째 힌트만 유효

SELECT /*+ FULL(SCOTT.EMP) */ 			-> 스키마명까지 명시하면 무효가 된다.
FROM EMP;


SELECT /*+ FULL(EMP) */					-> FROM 절 테이블명 옆에 ALIAS를 지정했다면, 힌트에도 반드시 ALIAS를 사용해야 한다.
FROM EMP E

 

자주 사용하는 힌트 목록

'Back-End > DB' 카테고리의 다른 글

데이터 저장 구조 및 I/O 메커니즘  (2) 2024.07.29
SQL 공유 및 재사용  (0) 2024.07.26
Lock 과 트랜잭션 동시성 제어 (Oracle)  (3) 2024.07.23
인덱스의 기본 (2)  (0) 2023.10.12
인덱스의 기본  (3) 2023.10.11

Lock은 데이터베이스의 특징을 결정짓는 가장 핵심적인 메커니즘이다. 자신이 사용하는 데이터베이스의 고유한 Lock 메커니즘을 이해하지 못한다면, 고품질, 고성능 애플리케이션을 구축하기 어렵다.

 

 

오라클 Lock

오라클은 공유 리소스와 사용자 데이터를 보호할 목적으로 DML Lock, DDL Lock, 래치, 버퍼 Lock, 라이브러리 캐시 Lock/pin 등 다양한 종류의 Lock을 사용한다. 이 외에도 내부에 더 많은 종류의 Lock 이 존재한다.

 

애플링케이션 개발 측면에서 가장 중요하게 다루어야 할 Lock은 무엇보다 DML Lock 이다. DML Lock은 다중 트랜잭션이 동시에 액세스하는  사용자 데이터의 무결성을 보호해 준다.

  • 테이블 Lock
  • 로우 Lock

DML 로우 Lock

DML 로우 Lock은, 두 개의 동시 트랜잭션이 같은 로우를 변경하는 것을 방지한다. 하나의 로우를 변경하려면 로우 Lock을 먼저 설정해야 한다. 어떤 DBMS이든지 DML 로우 Lock에는 배타적 모드를 사용하므로 UPDATE 또는 DELETE를 진행 중인(아직 커밋하지 않은) 로우를 다른 트랜잭션이 UPDATE 하거나 DELETE 할 수 없다.

INSERT에 대한 로우 Lock 경합은 Unique 인덱스가 있을 때만 발생한다. 즉, Unique 인덱스가 있는 상황에서 두 트랜잭션이 같은 값을 입력하라고 할 때, 블로킹이 발생한다. 블로킹이 발생하면, 후행 트랜잭션이 기다렸다가 선행 트랜잭션이 커밋하면 INSERT에 실패하고, 롤백하면 성공한다. 두 트랜잭션이 서로 다른 값을 입력하거나 Unique 인덱스가 아예 없으면, INSERT에 대한 로우 Lock 경합은 발생하지 않는다.

 

MVCC 모델을 사용하는 오라클은(for update절이 없는) SELECT 문에 로우 Lock을 사용하지 않는다. 오라클은 다른 트랜잭션이 변경한 로우를 읽을 때 복사본 블록을 만들어서 쿼리가 '시작된 지점'으로 되돌려서 읽는다. 변경이 진행 중인(아직 커밋하지 않은) 로우를 읽을 때도 Lock이 풀릴 때까지 기다리지 않고 복사본을 만들어서 읽는다. 따라서 SELECT 문에 Lock을 사용할 필요가 없다.

 

결국, 오라클에서는 DML과 SELECT는 서로 진행을 방해하지 않는다. 물론 SELECTㄲ리도 서로 방해하지 않는다. DML끼리는 서로 방해할 수 있는데, 이는 어떤 DBMS를 사용하더라도 마찬가지다.

참고로, MVCC 모델을 사용하지 않는 DBMS는 SELECT 문에 공유 Lock을 사용한다. 공유 Lock끼리는 호환된다. 두 트랜잭션이 같이 Lock을 설정할 수 있다는 뜻이다. 반면, 공유 Lock과 배타적 Lock은 호환되지 않기 때문에 DML과 SELECT가 서로 진행을 방해할 수 있다. 

즉, 다른 트랜잭션이 읽고 있는 로우를 변경하려면 다음 레코드로 이동할 때까지 기다려야 하고, 다른 트랜잭션이 변경 중인 로우를 읽으려면 커밋할 때까지 기다려야 한다.

 

 

DML 테이블 Lock

오라클은 DML 로우 Lock을 설정하기에 앞서 테이블 Lock을 먼저 설정한다. 현재 트랜잭션이 갱신 중인 테이블 구조를 다른 트랜잭션이 변경하지 못하게 막기 위해서다. 테이블 Lock을 "TM Lock"이라고 부르기도  한다.

테이블 Lock (O 표시는 두 모드 간에 호환성이 있음을 의미한다.)

 

선행 트랜잭션과 호환되지 않는 모드로 테이블 Lock을 설정하려는 후행 트랜잭션은 대기하거나 작업을 포기해야 한다.

 

테이블 Lock이라고 하면, 테이블 전체에 Lock이 걸린다고 생각하기 쉽다. 그래서 다른 트랜잭션이 더는 레코드를 추가하거나 갱신하지 못한다고 생각하는 사람이 많다. 하지만 DML을 수행하기 전에 항상 테이블 Lock을 먼저 설정하므로 그렇게 이해하는 것은 맞지 않다. 하나의 로우를 변경하기 위해 테이블 전체에 Lock을 건다면 동시성이 좋은 어플리케이션을 구현하기 어렵다.

 

오라클에서 말하는 테이블 Lock은, 자신(테이블 Lock을 설정한 트랜잭션)이 해당 테이블에서 현재 어떤 작업이 수행 중인지를 알리는 일종의 푯말이다. 위의 표처럼 테이블 Lock에는 여러 가지 모드가 있고, 어떤 모드를 사용하는지에 따라 후행 트랜잭션이 수행할 수 있는 작업의 범위가 결정된다. 

 

예를 들어, DDL을 이용해 테이블 구조를 변경하려는 트랜잭션은 해당 테이블에 TM Lock이 설정돼 있는지를 먼저 확인한다. TM Lock을 RX(=SX) 모드로 설정한 트랜잭션이 하나라도 있으면, 현재 테이블을 갱신 중인 트랜잭션이 있다는 신호다. 따라서 ORA-00054 메시지를 남기고 작업을 멈춘다. 

 

Lock을 푸는 열쇠, 커밋

블로킹(Blocking)은 선행 트랜잭션이 설정한 Lock 때문에 후행 트랜잭션이 작업을 진행하지 못하고 멈춰 있는 상태를 말한다. 이것을 해소하는 방법은 커밋(또는 롤백)뿐이다.

교착상태(DeadLock)는 두 트랜잭션이 각각 특정 리소스에 Lock을 설정한 상태에서 맞은편 트랜잭션이 Lock을 설정한 리소스에 또 Lock을 설정하려고 진행하는 상황을 말한다. 교착상태가 발생하면 둘 중 하나가 뒤로 물러나지 않으면 영영 풀릴 수 없다. 

오라클에서 교착상태가 발생하면, 이를 먼저 인지한 트랜잭션이 문장 수준 롤백을 진행한 후에 아래 에러 메시지를 던진다. 교착상태를 발생시킨 문장 하나만 롤백하는 것이다.

 

ORA-00060 : deadlock detected while waiting for resource

 

이제 교착상태는 해소됐지만 블로킹 상태에 놓이게 된다. 따라서 이 메시지를 받은 트랜잭션은 커밋 또는 롤백을 결정해야 한다. 만약 프로그램 내에서 이 에러에 대한 예외처리를 하지 않는다면 대기 상태를 지속하게 되므로 주의가 필요하다.

 

트랜잭션이 너무 길면, 트랜잭션을 롤백해야 할 때 너무 많은 시간이 걸려 고생할 수 있다. 따라서 같은 데이터를 갱신하는 트랜잭션이 동시에 실행되지 않도록 애플리케이션을 설계해야 하고, DML Lock 때문에 동시성이 저하되지 않도록 적절한 시점에 커밋해야 한다.

 

배치 커밋 명령어

  • WAIT(Default) : LGWR가 로그버퍼를 파일에 기록했다는 완료 메시지를 받을 때까지 기다린다(동기식 커밋)
  • NOWWAIT : LGWR의 완료 메시지를 기다리지 않고 바로 다음 트랜잭션을 진행한다(비동기식 커밋)
  • IMMEDIATE(Default) : 커밋 명령을 받을 때마다 LGWR가 로그 버퍼를 파일에 기록한다.
  • BATCH : 세션 내부에 트랜잭션 데이터를 일정량 버퍼링했다가 일괄 처리한다.

이들 옵션을 조합해 아래와 같이 커밋 명령을 사용할 수 있다.

SQL > COMMIT WRITE IMMEDIATE WAIT;
SQL > COMMIT WRITE IMMEDIATE NOWAIT;
SQL > COMMIT WRITE BATCH WAIT;
SQL > COMMIT WRITE BATCH NOWAIT;

 

 

 

 

트랜잭션 동시성 제어

동시성 제어는 비관적 동시성 제어와 낙관적 동시성 제어로 나뉜다. 비관적 동시성 제어(Pessimistic Concurrency Control)는 사용자들이 같은 데이터를 동시에 수정할 것으로 가정한다. 따라서 한 사용자가 데이터를 읽는 시점에 Lock을 걸고 조회 또는 갱신처리가 완료될 때까지 이를 유지한다. Lock은 첫 번째 사용자가 트랜잭션을 완료하기 전까지 다른 사용자들이 같은 데이터를 수정할 수 없게 만들기 때문에 비관적 동시성 제어를 잘못 사용하면 동시성이 나빠진다. 

 

반면, 낙관적 동시성 제어(Optimistic Concurrency Control)는 사용자들이 같은 데이터를 동시에 수정하지 않을 것으로 가정한다. 따라서 데이터를 읽을 때 Lock을 설정하지 않는다. 그런데 낙관적 입장에서 섰다고 해서 동시 트랜잭션에 의한 잘못된 데이터 갱신을 신경 쓰지 않아도 된다는 것은 아니다. 읽는 시점에 Lock을 사용하지 않았지만, 데이터를 수정하고자 하는 시점에 앞서 읽은 데이터가 다른 사용자에 의해 변경되었는지 반드시 검사해야 한다.

 

 

비관적 동시성 제어

우수 고객을 대상으로 적립포인트를 제공하는 이벤트를 제공한다고 가정해보자 이때 밑에 예시처럼 고객의 다양한 실적정보를 읽고 복잡한 산출공식을 이용해 적립포인트를 계산하는 동안(SELECT 문 이후, UPDATE 문 이전) 다른 트랜잭션이 같은 고객의 실적정보를 변경하다면 문제가 생길 수 있다.

select 적립포인트, 방문횟수, 최근방문일시, 구매실적 from 고객
where 고객포인트 = :cust_num;

-- 새로운 적립포인트 계산
update 고객 set 적립포인트 = :적립포인트 where 고객번호 = :cust_num

 

하지만, 아래와 같이 SELECT 문에 FOR UPDATE를 사용하면 고객 레코드에 Lock을 설정하므로 데이터가 잘못 갱신되는 문제를 방지할 수 있다.

select 적립포인트, 방문횟수, 최근방문일시, 구매실적 from 고객
where 고객포인트 = :cust_num for update;

 

비관적 동시성 제어는 자칫 시스템 동시성을 심각하게 떨어뜨릴 우려가 있지만, FOR UPDATE에 WAIT 또는 NOWAIT 옵션을 함께 사용하면 Lock을 얻기 위해 무한정 기다리지 않아도 된다.

for update nowait -- 대기없이 Exception(ORA-00054)을 던짐
for update wait 3 -- 3초 대기 후 Exception(ORA-30006)을 던짐

 

WAIT 또는 NOWAIT 옵션을 사용하면, 다른 트랜잭션에 의해 Lock이 걸렸을 때, Exception을 만나게 되므로 "다른 사용자에 의해 변경 중이므로 다시 시도하십시오" 라는 메시지를 출력하면서 트랜잭션을 종료할 수 있다. 따라서 오히려 동시성을 증가시키게 된다.

더보기

큐(Queue) 테이블 동시성 제어

 

큐 테이블에 쌓인 고객 입금 정보를 일정한 시간 간격으로 읽어서 입금 테이블에 반영하는 데몬 프로그램이 있다고 가정하다.

데몬이 여러 개이므로 Lock이 걸릴 수 있는 상황이다. Lock이 걸리면 3초간 대기했다가 다음에 다시 시도하게 하려고 아래와 같이 for update wait 3 옵션을 지정했다. 큐에 쌓인 데이터를 한 번에 다 읽어서 처리하면 Lock이 풀릴 때까지 다른 데몬이 오래 걸릴 수 있으므로 고객 정보를 100개씩만 읽도록 했다.

 

select cust_id, rcpt_amt from cust_rcpt_Q

where yn_upd = 'Y' and rownum <= 100 FOR UPDATE WAIT 3;

 

이럴 때 아래와 같이 skip locked 옵션을 사용하면, Lock이 걸린 레코드는 생략하고 다음 레코드를 계속 읽도록 구현할 수 있다.

 

select cust_id, rcpt_nm from cust_rcpt_Q

where yn_upd = 'Y' FOR UPDATE SKIP LOCKED;

 

 

낙관적 동시성 제어 

낙관적 동시성 제어 예시를 보자.

select 적립포인트, 방문횟수, 최근방문일시, 구매실적 into :a, :b, :c, :d
from 고객
where 고객번호 = :cust_num;

-- 새로운 적립포인트 계산
update 고객 set 적립포인트 = :적립포인트
where 고객번호 = :cust_num
and 적립포인트 = :a
and 방문횟수 = :b
and 최근방문일시 = :c
and 구매실적 = :d;

if sql%rowcount = 0 than
  alert("다른 사용자에 의해 변경되었습니다.");
end if;

 

SELECT 문에서 읽은 컬럼이 매우 많다면 UPDATE 문에 조건절을 일일이 기술하는 것이 귀찮을 것이다. 만약 UPDATE 대상 테이블에 최종변경일시를 관리하는 컬럼이 있다면, 이를 조건절에 넣어 간단하게 해당 레코드의 갱신여부를 판단할 수 있다.

select 적립포인트, 방문횟수, 최근방문일시, 구매실적, 변경일시
into :a, :b, :c, :d, :mod_dt
from 고객
where 고객번호 = :cust_num;

-- 새로운 적립포인트 계산
update 고객 set 적립포인트 = :적립포인트, 변경일시 = SYSDATE
where 고객번호 = :cust_num
and 변경일시 = :mod_dt; -> 최종 변경일시가 앞서 읽은 값과 같은지 비교

if sql%rowcount = 0 than
  alert("다른 사용자에 의해 변경되었습니다.");
end if;

 

낙관적 동시성 제어에서도 UPDATE 전에 아래 SELECT 문을 한 번 더 수행함으로써 Lock에 대한 예외처리를 한다면, 다른 트랜잭션이 설정한 Lock을 기다리지 않게 구현할 수 있다.

select 고객번호
from 고객
where 고객번호 = :cust_num
and 변경일시 = :mod_dt
for update nowait;

 

 

 

동시성 제어 없는 낙관적 프로그래밍

낙관적 동시성 제어를 사용하면 Lock이 유지되는 시간이 매우 짧아져 동시성을 높이는 데 매우 유리하다. 하지만 다른 사용자가 같은 데이터를 변경했는지 검사하고 그에 따라 처리 방향성을 결정하는 귀찮은 절차가 뒤따른다. 

 

예를 들어, 온라인 쇼핑몰에서 특정 상품을 조회해서 결제를 완료하는 순간까지를 하나의 트랜잭션으로 정의했다고 가정해보자.

 

위의 그림에서 보듯, TX1이 t1 시점에 상품을 조회할 때는 가격기 1,000원이었다. 주문을 진행하는 동안 TX2에 의해 가격이 1,200원으로 수정되었다면, TX1이 최종 결제 버튼을 클릭하는 순간 어떻게 처리해야 할까? 상품 가격의 변경 여부를 체크함으로써 해당 주문을 취소시키거나 사용자에게 변경사실을 알리고 처리방향을 확인받는 프로레스를 거쳐야 한다.

insert into 주문
select :상품코드, :고객ID, :주문일시, :상점번호, ....
from 상품
where 상품코드 = :상품코드
and 가격 = :가격; -- 주문을 시작한 시점 가격

if sql%rowcount = 0 than
 alert("상품 가격이 변경되었습니다.");
end if;

 

하지만 이런 로직은 찾기 힘들다. 주문을 진행하는 동안 상품 공급업체가 가격을 변경하지 않을 것이라고 낙관적으로 생각하기 때문이다.

 

 

'Back-End > DB' 카테고리의 다른 글

SQL 공유 및 재사용  (0) 2024.07.26
SQL 파싱과 최적화  (3) 2024.07.25
인덱스의 기본 (2)  (0) 2023.10.12
인덱스의 기본  (3) 2023.10.11
인덱스를 사용하는 이유  (0) 2023.09.14

자바의 예외처리 방법인 try-catch에 대해서 정리해보자

 

다음의 코드를 보고 결과값을 생각해보자

public static void main(String[] args) throws Exception {
	
    for(int i = 0; i < 10; i++) {
    	System.out.println(i);
        
        if(i==5) {
        	throw new Exception();
        }   
    }
}

 

해당 코드를 실행하면 출력값은 어떻게 될까???

 

출력값 : 0 1 2 3 4 5 

 

5번 인덱스 출력 후  중단된다.

 

그럼 다음 코드의 출력값은 어떨까?

 

public static void main(String[] args) throws Exception {
	
    for(int i = 0; i < 10; i++) {
    	System.out.println(i);
        
        if(i==5) {
        	try {
            	throw new Exception();
            } catch (Exception e) {
            	e.printStackTrace();
            }
        	
        }   
    }
}

 

출력값 : 0 1 2 3 4 5 6 7 8 9

 

try-catch 내에서 예외가 발생하면 해당 예외는 catch 블록을 통해 예외가 처리된다.

따라서, 정상적으로 for loop가 작동하게 된다.

 

반면에, 맨위 코드의 경우 throw new Exception(); 을 통해 예외를 던지고 있다. 하지만 이것을 받아줄 catch 부분이 없기 때문에 종료되게 된다.

 

 

다중 try-catch

public static void main(String[] args) {

		try {
			System.out.println("외부 try 로직 수행.");
			try {
				System.out.println("내부 try 로직 수행");
				Exception e = new Exception();
				throw e;
			} catch (Exception e) {
				System.out.println("내부 try catch문 exeption : " + e);
				System.out.println("예외 던지기 한번 더");
				throw e;
			}finally {
				System.out.println("finally 구문 출력");
			}
		} catch (Exception e) {
			System.out.println("외부 try - catch exception : " + e);
		}
		System.out.println("종료");
	}

위 코드의 실행 순서를 생각해보자.

  1. '외부 try 로직 수행.' 출력
  2. '내부 try 로직 수행.' 출력
  3. '내부 try catch문 exception : java.lang.Exception' 출력
  4. '예외 던지기 한번 더' 출력
  5. 'finally 구문 출력' 출력
  6. '외부 try - catch exception : java.lang.Exception' 출력
  7. '종료' 출력

내부의 예외처리부분인 catch에서 강제로 Exception 을 한번 더 발생시켰을때 처리해줄 catch 부분이 외부 catch 부분이기 때문에

'외부 try - catch exception : java.lang.Exception' 이 출력되게 된다.

'Back-End > Java' 카테고리의 다른 글

HTTP 요청 하나당 스레드는 몇 개나 동작할까?  (0) 2025.05.12
할머니도 이해할 수 있는 자바 Thread  (0) 2025.05.01
스레드  (1) 2024.07.05
프로세스  (0) 2024.07.04
Java HTTP 통신  (4) 2024.06.12

사전적 의미로 한 가닥의 실이라는 뜻으로 한가지 작업을 실행하기 위해 순차적으로 실행할 코드를 실처럼 이어놓았다고 해서 유래된 이름이다. 하나의 스레드는 하나의 코드 실행 흐름이므로 한 프로세스 내 스레드가 2개라면 2개의 실행 흐름이 생긴다.

 

프로세스가 할당받은 자원을 이용하는 실행의 단위이며

  • 프로세스 내의 명령어 블록으로 시작점과 종료점을 가진다.
  • 실행중에 멈출 수 있으며 동시에 수행 가능하다.
  • 어떠한 프로그램 내에서 특히 프로세스 내에서 실행되는 흐름의 단위이다.

자바에서는 멀티 스레딩을 통해 하나의 프로세스 내에서 여러 스레드가 병렬로 실행될 수 있다.

 

스레드의 특징

  1. 공유 주소 공간 : 같은 프로세스 내의 스레드들은 동일한 메모리 공간을 공유한다. 이를 통해 스레드 간 데이터 공유와 통신이 매우 효율적이다.
  2. 독립적인 실행 흐름 : 각 스레드는 자신만의 실행 흐름을 가지며, 독립적으로 스케줄링되어 실행할 수 있다.
  3. 스레드 동기화 : 스레드들이 같은 자원을 동시에 접근할 때 발생할 수 있는 문제를 방지하기 위해 동기화 메커니즘이 필요하다 자바에서는 'synchronized' 키워드와 'java.util.concurrent' 패키지의 여러 클래스를 통해 동기화를 지원한다.
  4. 생성 및 관리 : 자바에서 스레드는 두 가지 방법으로 실행할 수 있다. 
    1. 'Tread' 클래스를 상속받아 새로운 클래스를 만들고 'run()' 메서드를 오버라이딩
    2. 'Runnable' 인터페이스를 구현하여 스레드 객체에 전달
// Thread 클래스를 상속받아 스레드 생성
class MyThread extends Thread {
    public void run() {
        System.out.println("Thread is running.");
    }
}

MyThread t1 = new MyThread();
t1.start();

// Runnable 인터페이스를 구현하여 스레드 생성
class MyRunnable implements Runnable {
    public void run() {
        System.out.println("Thread is running.");
    }
}

Thread t2 = new Thread(new MyRunnable());
t2.start();

 

스레드의 동작을 확인할 수 있는 예제

public class Sample extends Thread {
    int seq;

    public Sample(int seq) {
        this.seq = seq;
    }

    public void run() {
        System.out.println(this.seq + " thread start.");  // 쓰레드 시작
        try {
            Thread.sleep(1000);  // 1초 대기한다.
        } catch (Exception e) {
        }
        System.out.println(this.seq + " thread end.");  // 쓰레드 종료 
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {  // 총 10개의 쓰레드를 생성하여 실행한다.
            Thread t = new Sample(i);
            t.start();
        }
        System.out.println("main end.");  // main 메서드 종료
    }
}

 

위의 예제는 총 10개의 스레드를 실행시키는 예제이다. 어떤 스레드인지 확인하기 위해 스레드마다 생성자에 순서를 부여한다. 그리고 시작과 종료를 출력하게 했고 시작과 종료 사이에 1초의 간격이 생기도록 Tread.slepp(1000) 을 작성하였다. 그리고 main 메서드 종료 시 'main.end'를 출력하도록 했다. 결과를 보자

0 thread start.
4 thread start.
6 thread start.
2 thread start.
main end.
3 thread start.
7 thread start.
8 thread start.
1 thread start.
9 thread start.
5 thread start.
0 thread end.
4 thread end.
2 thread end.
6 thread end.
7 thread end.
3 thread end.
8 thread end.
9 thread end.
1 thread end.
5 thread end.

 

결과는 출력 때마다 다르다. 0번 스레드부터 9번 스레드까지 순서대로 실행되지 않고, 그 순서가 일정하지 않은 것을 보면 스레드는 순서에 상관없이 동시에 실행된다는 사실을 알 수 있다. 더욱 놀라운 사실은 스레드가 종료되기 전 main 메서드가 종료되었다는 사실이다.

 

그렇다면 모든 스레드가 종료된 후에 main 메서드를 종료하고 싶은 경우에는 어떻게 해야 할까?

import java.util.ArrayList;

public class Sample extends Thread {
    int seq;
    public Sample(int seq) {
        this.seq = seq;
    }

    public void run() {
        System.out.println(this.seq+" thread start.");
        try {
            Thread.sleep(1000);
        }catch(Exception e) {
        }
        System.out.println(this.seq+" thread end.");
    }

    public static void main(String[] args) {
        ArrayList<Thread> threads = new ArrayList<>();
        for(int i=0; i<10; i++) {
            Thread t = new Sample(i);
            t.start();
            threads.add(t);
        }

        for(int i=0; i<threads.size(); i++) {
            Thread t = threads.get(i);
            try {
                t.join(); // t 쓰레드가 종료할 때까지 기다린다.
            }catch(Exception e) {
            }
        }
        System.out.println("main end.");
    }
}

 

생성된 스레드를 ArrayList 객체인 threads 에 담은 후 main 메서드가 종료되기 전에 threads 객체에 담긴 각각의 스레드에 join 메서드를 호출하여 스레드가 종료될때까지 대기하도록 했다. join 메서드는 스레드가 종료될 때까지 기다리게 하는 메서드이다.

 

0 thread start.
5 thread start.
2 thread start.
6 thread start.
9 thread start.
1 thread start.
7 thread start.
3 thread start.
8 thread start.
4 thread start.
0 thread end.
5 thread end.
2 thread end.
9 thread end.
6 thread end.
1 thread end.
7 thread end.
4 thread end.
8 thread end.
3 thread end.
main end.

 

이렇게 하면 'main end.' 문자열이 가장 마지막에 출력되는 것을 확인할 수 있다.

'Back-End > Java' 카테고리의 다른 글

할머니도 이해할 수 있는 자바 Thread  (0) 2025.05.01
try-catch  (0) 2024.07.06
프로세스  (0) 2024.07.04
Java HTTP 통신  (4) 2024.06.12
Exception과 Transaction  (1) 2024.02.05

프로세스(Process)

운영체제로부터 자원을 할당받는 작업의 단위

사용자가 어플리케이션을 실행하면, 운영체제로부터 실행에 필요한 메모리를 할당받아 어플리케이션의 코드를 실행하는데 이것을 프로세스라고 부른다.

 

ex) Chrome 브라우저 2개 실행 = 두 개의 Chrome 프로세스가 생성 되었다.

 

자바에서 프로세스는 자바 가상 머신(JVM)이 운영체제에서 프로그램을 실행할때 생성된다. 프로세스는 독립된 실행 환경을 가지며, 다음과 같은 특징을 가진다.

  1. 독립된 주소 공간 : 프로세스는 자신의 메모리 공간을 독립적으로 가지고 있다. 다른 프로세스와 메모리 공간을 공유하지 않기 때문에 하나의 프로세스에서 발생한 오류가 다른 프로세스에 영향을 미치지 않는다.
  2. 자원 할당 : 프로세스는 CPU 시간, 메모리, 파일 핸들 등의 자원을 할당받는다.
  3. 프로세스 간 통신 : 서로 다른 프로세스는 기본적으로 독립적이기 때문에, 프로레스 간의 데이터 교환은 인터프로세스 커뮤니케이션(IPC) 기법을 통해 이루어져야 한다. IPC 방법으로는 소켓, 파일, 공유 메모리, 메시지 큐 등이 있다.

자바에서 새로운 프로세스를 생성하려면 

  1. Runtime.getRuntime().exec() 메서드를 사용하거나
  2. ProcessBuilder 클래스를 사용할 수 있습니다.

 

Runtime 클래스의 exec 메서드를  사용

import java.io.*;

public class ProcessExample {
    public static void main(String[] args) {
        try {
            // 새로운 프로세스를 생성하여 명령어 실행
            Process process = Runtime.getRuntime().exec("notepad.exe");

            // 프로세스의 출력 스트림 읽기 (예시에서는 필요 없지만 다른 명령어 실행 시 유용할 수 있음)
            BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }

            // 프로세스 종료 대기
            int exitCode = process.waitFor();
            System.out.println("Process exited with code: " + exitCode);
        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

 

ProcessBuilder 클래스를 사용하는 예시

mport java.io.*;

public class ProcessBuilderExample {
    public static void main(String[] args) {
        // ProcessBuilder 객체 생성
        ProcessBuilder processBuilder = new ProcessBuilder("ping", "-c", "4", "google.com");

        // 프로세스 출력과 오류를 동일한 스트림으로 병합
        processBuilder.redirectErrorStream(true);

        try {
            // 새로운 프로세스 시작
            Process process = processBuilder.start();

            // 프로세스의 출력 스트림 읽기
            BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }

            // 프로세스 종료 대기
            int exitCode = process.waitFor();
            System.out.println("Process exited with code: " + exitCode);
        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

 

프로세스 간 통신 예시

자바에서는 소켓을 사용하여 프로세스 간 통신을 구현할 수 있다. 간단한 서버-클라이언트 예제를 통해 프로세스 간 통신을 알아보자

 

서버 예제

import java.io.*;
import java.net.*;

public class Server {
    public static void main(String[] args) {
        // 서버 소켓을 생성하여 포트 5000에서 클라이언트 연결 대기
        try (ServerSocket serverSocket = new ServerSocket(5000)) {
            System.out.println("Server started. Waiting for a client...");

            // 클라이언트 연결 수락
            try (Socket clientSocket = serverSocket.accept();
                 // 클라이언트와 통신을 위한 출력 스트림 생성
                 PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
                 // 클라이언트와 통신을 위한 입력 스트림 생성
                 BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()))) {

                System.out.println("Client connected.");

                String inputLine;
                // 클라이언트로부터 데이터 수신 및 처리
                while ((inputLine = in.readLine()) != null) {
                    System.out.println("Received: " + inputLine);
                    // 클라이언트로 데이터 전송
                    out.println("Echo: " + inputLine);
                }
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

 

클라이언트 예제

import java.io.*;
import java.net.*;

public class Client {
    public static void main(String[] args) {
        // 서버에 연결
        try (Socket socket = new Socket("localhost", 5000);
             // 서버로 데이터 전송을 위한 출력 스트림 생성
             PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
             // 서버로부터 데이터 수신을 위한 입력 스트림 생성
             BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
             // 사용자 입력을 위한 표준 입력 스트림 생성
             BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in))) {

            String userInput;
            // 사용자 입력을 읽어 서버로 전송하고, 서버의 응답을 출력
            while ((userInput = stdIn.readLine()) != null) {
                out.println(userInput);
                System.out.println("Server response: " + in.readLine());
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

 

이 예시는 서버가 클라이언트의 메시지를 받아 그대로 돌려주는 간단한 코드이다. 

'Back-End > Java' 카테고리의 다른 글

try-catch  (0) 2024.07.06
스레드  (1) 2024.07.05
Java HTTP 통신  (4) 2024.06.12
Exception과 Transaction  (1) 2024.02.05
String, StringBuilder, StringBuffer 의 차이점  (1) 2023.10.26

+ Recent posts