프로그램상에서 작성한 문서의 데이터는 로컬 데이터베이스에 저장하여 관리할 것이다. 그러기 위해서는

먼저 DBMS(DataBase Management System)를 설치해야한다.  여기서는 PostreSQL 을 사용할 것이다.

 

 

1. PostgreSQL 설치

먼저 PostgreSQL 공식 홈페이지에 접속하여 Download 페이지로 들어간다.

 

PostgreSQL

The world's most advanced open source database.

www.postgresql.org

 

 

 윈도우 환경에 설치할 것이기 때문에 Windows 를 선택한다.

 

 

Download the installer 를 클릭하고 운영체제에 맞는 최신버전을 다운로드한다. 다운로드 받은 설치파일을

실행하고 next를 눌러 설치를 진행하다보면 데이터베이스의 슈퍼유저(postgres) 계정을 생성하기 위해 암호를

입력받는다.  이후에 별도로 건드릴 것은 없으니 계속 next를 눌러 설치를 마친다. 

 

 

2. 데이터베이스 서버 생성

PostgreSQL을 설치했으니 이제 데이터를 저장할 DB 서버를 생성해야한다. 위의 설치과정에서 자동으로

설치됐을 PostgreSQL의 GUI툴인 pgAdmin 을 실행하여 위와 같이 server 를 우클릭하여 새 서버를 생성한다.

pgAdmin 실행시 요구하는 암호는 PostgreSQL 설치시에 입력한 슈퍼유저 계정의 암호를 입력해주면 된다.

 

먼저 General 탭의 Name 에는 서버의 이름을 입력해준다.  무엇으로 하든 상관없으니 원하는 이름을 입력한다.

 

Connection 탭에서는 호스트 이름 혹은 주소, 포트번호, 유저명, 패스워드를 입력한다. 로컬 DB로 사용할 것이기

때문에 호스트는 localhost로 해주고 포트번호는 PostgreSQL의 기본 포트번호인 5432를 그대로 사용한다.

유저명과 패스워드는 DB를 사용할 계정의 것을 입력해야한다.  여기서는 슈퍼유저 계정인 postgres를 사용한다.

 

모두 입력한 뒤 save를 누르면 서버가 생성된 것을 볼 수 있다.

 

 

3. sqlAlchemy 설치

 sqlAlchemy는 Python에서 데이터베이스를 다루기 위한 SQL 툴킷이며 ORM(Object Relational Mapper)이다.

ORM은 데이터베이스의 데이터들을 객체의 형태로 매핑해주는 것으로 사용자가 SQL문에 의존하지 않고

객체지향적인 코드로 DB를 다룰 수 있게 해준다.  sqlAlchemy 는 현재 Python 웹 프레임워크인 Flask에서도

사용되고있으며 마찬가지로 Python 웹 프레임워크인 Django 의 ORM과 함께 Python에서 가장 널리 사용되는

ORM 툴이다.  여기서는 sqlAlchemy 를 사용하여 애플리케이션을 DB와 연결해본다.

 

 

pip install sqlalchemy

설치는 매우 간단하다. 현재 개발환경인 Pycharm의 Terminal은 기본적으로 가상환경에 진입한 상태이기 때문에

터미널상에서 바로 pip 명령어를 사용하여 설치해주면 된다. 

 

 

4. db_manager.py

import json

from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import scoped_session, sessionmaker

# 데이터베이스 패스워드를 저장한 secrete_file.json 을 읽는다
with open('secrete_file.json') as f:
    secretes = json.loads(f.read())

# postgresql 의 형식에 따라 엔진 생성
engine = create_engine('postgresql://{username}:{db_password}@{host}:{port}/{db_name}'.format(
    username='postgres',
    db_password=secretes['db_password'],
    host='localhost',
    port='5432',
    db_name='postgres'
))

# DB 세션 생성
session = scoped_session(sessionmaker(autocommit=False, autoflush=False, bind=engine))

# 모델 매핑
Base = declarative_base()
Base.query = session.query_property()

# db 초기화
def init_db():
    Base.metadata.create_all(engine)
  • controller 디렉토리에 db_manager.py 를 위와 같이 작성

  • secrete_file.json 에서 데이터베이스의 패스워드를 읽음

  • create_engine 에 postgresql의 형식에 맞춰 정보를 입력해주는 것으로 엔진을 생성

  • sessionmaker와 scoped_session 을 사용하여 엔진과 연결된 세션을 생성
    • autocommit과 autoflush를 False 로 하는 것은 여러개의 쿼리문을 적용해야할 때 한번에 commit을
      실행하기 위해서이다.(flush가 발생하면 db에 실제로 삽입, 삭제 등의 연산을 수행한 상태가 된다.
      다만 commit까지는 가지 않는다. commit은 내부적으로 flush를 먼저 수행한다.)

  • declarative_base 를 사용하여 Base를 상속하여 선언한 클래스(모델)들을 매핑해준다.

  • init_db를 호출하면 연결된 db에 선언한 모델들을 기반으로 테이블을 생성한다.

 

 

5. 모델 선언

from sqlalchemy import Column, Integer, String, DateTime
from controller.db_manager import Base


# 화주 모델
class DayCalOwner(Base):
    __tablename__ = 'daycal_owner'
    name = Column(String, primary_key=True)

    def __init__(self, name):
        self.name = name


# 화주별 일일정산 데이터 모델
class DayCalOwnerValues(Base):
    __tablename__ = 'daycal_owner_values'
    datetime = Column(DateTime, primary_key=True)
    owner_name = Column(String, primary_key=True)
    kd_total = Column(Integer)
    kd_fare = Column(Integer)
    kd_drop = Column(Integer)
    match_fee5 = Column(Integer)
    owner_fare = Column(Integer)
    owner_drop = Column(Integer)
    listing_fee4 = Column(Integer)
    kd_pre = Column(Integer)

    def __init__(self, datetime, owner):
        self.datetime = datetime
        self.owner = owner


# 일일정산서에 입력될 기타 데이터 모델
class DayCalOtherValues(Base):
    __tablename__ = 'daycal_other_values'
    datetime = Column(DateTime, primary_key=True)
    office_deposit = Column(Integer)
    kd_deposit = Column(Integer)
    direct_exp = Column(Integer)
    our_auc = Column(Integer)
    kd_buy = Column(Integer)

    def __init__(self, datetime):
        self.datetime = datetime
  • model 디렉토리의 models.py에 일일정산서에 필요한 모델들을 선언
  • 모델 클래스는 모두 Base 를 상속

 

 

6. DB 초기화

from PySide6.QtWidgets import QTabWidget, QWidget, QTableWidget, QGridLayout
from controller.db_manager import session, init_db
from models.models import DayCalOwner


# 일일 정산서 계산서 위젯
class DayCal(QWidget):
    # 생성자
    def __init__(self):
        super().__init__()
        self.init_ui()

    # ui 초기화
    def init_ui(self):
        init_db()
        # 화주 목록
        owners = [q.name for q in session.query(DayCalOwner)]
        input_table = QTableWidget()
        input_table.setColumnCount(len(owners))
        input_table.setHorizontalHeaderLabels(owners)
        input_table.setRowCount(19)
        input_table.setVerticalHeaderLabels([
            '강동총금액', '강동운임', '강동하차비', '강동수수료 4%', '공제후금액', '',
            '중매수수료 5%', '화주운임', '화주하차비', '상장수수료 4%', '강동선지급금', '공제합계', '선지급금포함 공제합계', '',
            '경매 사무실입금', '가라경매 강동입금', '직접지출', '우리경매', '강동사입'
                                       ])

        # 그리드 레이아웃
        grid = QGridLayout()

        # 테이블위젯 추가
        grid.addWidget(input_table, 0, 0)

        # 레이아웃 세팅
        self.setLayout(grid)

 이 작업은 DB 세팅시에 한번만 수행해주면 된다.  원래라면 이 세팅은 별도의 파일로 분리해야하지만 여기서는

우선 테스트를 위해 DB를 처음 사용할 DayCal 위젯의 init_ui에서 init_db 함수를 실행하여 수행해주자.  그리고

이전에는 임시로 화주1, 화주2, 화주3을 넣어주었던 owners를 DB 세션에서 query를 통해 DayCalOwner,

즉 화주 데이터를 가져온 후 그 이름으로 이루어진 리스트를 구성한다.  이제 column 의 헤더 레이블은 DB에

등록된  화주명단에 기반하여 붙여질 것이다. 

 

그런데 위의 작업가지 완료한 후 애플리케이션으 구동해보면 위와 같이 column이 아예 없어진 것을 볼 수 있다.

테이블만 생성했을 뿐 추가된 화주가 하나도 없기 때문이다.  다음 포스팅에서는 화주 추가 기능을 구현하여

실제로 데이터베이스에 등록된 화주 리스트에 따라 표가 나타나도록 해본다.

'개인 프로젝트 > Accounting Program' 카테고리의 다른 글

#15 모델/구조 변경, 화주 추가 기능 수정  (0) 2021.11.02
#14 화주 추가  (0) 2021.11.01
#12 메인 화면 구성  (0) 2021.10.28
#11 종료시 위치 기억  (0) 2021.10.26
#10 프로젝트 구조 변경  (0) 2021.10.20

소켓 통신의 이해를 위해 Python으로 간단한 소켓 통신을 구현해본다.

 

1. 서버 측

from socket import socket, AF_INET, SOCK_STREAM
from threading import Thread

BUF_SIZ = 1024
user_list = dict()


# 수신 핸들러
def receive_handler(client_socket, user_name):
    # 클라이언트 소켓으로부터 메시지 수신대기
    while True:
        try:
            data = client_socket.recv(BUF_SIZ).decode()

        # 클라이언트와 연결이 끊어진 경우 반복 종료
        except:
            print(user_name, '와의 연결이 끊어졌습니다.')
            break

        # 클라이언트가 종료 명령어를 입력한 경우 반복 종료
        if data == '/종료':
            break

        # 유저명: 메시지 형태로 다이얼로그 생성
        dialog = f'{user_name}: {data}'

        # 자신과 연결된 다른 클라이언트들에게 메시지 출력
        print(dialog)
        for user in user_list:
            # 메시지를 보낸 유저에겐 출력하지 않음
            if user == user_name:
                continue
            user_list[user].sendall(dialog.encode())

    # 클라이언트 연결 종료
    # 유저 리스트에서 해당 유저 제거
    del user_list[user_name]

    # 자신과 연결된 다른 클라이언트들에게 해당 유저의 퇴장알림 메시지 출력
    msg = user_name+'님이 퇴장하셨습니다.'
    print(msg)
    for user in user_list:
        user_list[user].sendall(msg.encode())

    # 클라이언트 소켓 close
    client_socket.close()


if __name__ == '__main__':
    # 서버측 소켓 생성(socket)
    # 주소 체계와 소켓 타입을 입력하여 생성한다.
    # 인터넷 IPv4 주소체계를 사용하기 위해 AF_INET 을 사용
    # TCP 소켓을 사용하기 위해 SOCK_STREAM 을 사용
    server_socket = socket(AF_INET, SOCK_STREAM)

    # 바인딩에 성공할 때 까지 입력을 다시받음
    while True:
        try:
            print('HOST IP: ', end='')
            HOST = input()
            print('PORT: ', end='')
            PORT = int(input())

            # 연결할 서버의 주소, 포트를 입력하여 연결 요청
            server_socket.bind(('localhost', PORT))
            break
        except:
            continue

    # 수신 대기열 생성
    # 입력한 숫자만큼 대기열을 생성
    server_socket.listen(5)
    print('>> 채팅방 개설')

    # 무한루프
    while True:
        try:
            # 클라이언트로부터 로그인 요청을 대기
            connection_socket, addr = server_socket.accept()

            # 유저 등록
            user_name = connection_socket.recv(BUF_SIZ).decode()

            # 이미 존재하는 이름으로 로그인 시도시 재입력 요구
            while user_name in user_list:
                connection_socket.send('exist username'.encode())
                user_name = connection_socket.recv(BUF_SIZ).decode()

            # 로그인 성공시 성공메시지를 송신하고 유저리스트에 추가
            user_list[user_name] = connection_socket
            connection_socket.send('ok'.encode())

            # 클라이언트로부터 메시지를 수신하기 위한 스레드 실행
            receive_thread = Thread(target=receive_handler, args=(connection_socket, user_name))
            receive_thread.daemon = True
            receive_thread.start()

            # 해당 유저의 입장알림 메시지를 자신과 다른 연결된 클라이언트들에게 출력
            msg = user_name+'님이 입장하셨습니다.'
            print(msg)
            for user in user_list:
                user_list[user].sendall(msg.encode())

        # 서버 종료시 모든 클라이언트 소켓 close
        except KeyboardInterrupt:
            for user in user_list:
                user_list[user].close()

 

  • host 주소와 port 번호를 입력받아 채팅방 개설
     
  • 수신 대기열을 만들고 클라이언트로부터 연결요청을 대기

  • 연결요청이 들어오고 user_name이 전송되면 중복검사

  • 중복 user_name인 경우 재입력 요구

  • 중복되지 않는 user_name을 입력받을 경우 user_list에 user_name : connection_socket 의 형태로 저장

  • 해당 클라이언트로부터 메시지를 전송받기 위한 스레드를 daemon으로 생성
    => daemon 스레드는 메인 프로세스가 종료시 자신의 실행여부에 관계없이 종료된다.

  • 메시지 수신 핸들러는 클라이언트로부터 메시지를 전송받아 일반 메시지라면 유저명: 메시지 의 형태로
    포매팅하여 연결된 전 클라이언트에게 전송.  종료 명령어라면 해당 클라이언트의 소켓을 close 하고
    해당 클라이언트의 퇴장을 연결중인 전 클라이언트에게 알림

  • 해당 유저의 입장을 연결중인 전 클라이언트에게 알림

  • 서버 종료시 모든 클라이언트 소켓 close

 

 

2. 클라이언트 측

from socket import socket, AF_INET, SOCK_STREAM
from threading import Thread

BUF_SIZ = 1024


# 수신 핸들러
def receive_handler(client_socket):
    while True:
        try:
            data = client_socket.recv(BUF_SIZ)
            data = data.decode()
            print(data)
        except:
            print('연결 종료')
            break


# 송신 핸들러
def send_handler(client_socket):
    # 사용자가 '/종료' 를 입력하기 전까지 입력을 받아 서버에 전송
    while True:
        data = input()
        client_socket.sendall(data.encode())
        if data == '/종료':
            break
        
    # 소켓 close
    client_socket.close()


if __name__ == '__main__':
    # 클라이언트측 소켓 생성(socket)
    # 주소 체계와 소켓 타입을 입력하여 생성한다.
    # 인터넷 IPv4 주소체계를 사용하기 위해 AF_INET 을 사용
    # TCP 소켓을 사용하기 위해 SOCK_STREAM 을 사용
    client_socket = socket(AF_INET, SOCK_STREAM)

    # 연결에 성공할 때 까지 입력을 다시받음
    while True:
        try:
            print('HOST IP: ', end='')
            HOST = input()
            print('PORT: ', end='')
            PORT = int(input())

            # 연결할 서버의 주소, 포트를 입력하여 연결 요청
            client_socket.connect(('localhost', PORT))
            break
        except:
            continue

    # 로그인
    while True:
        print('USER_ID: ', end='')
        user = input()
        client_socket.send(user.encode())
        result = client_socket.recv(BUF_SIZ).decode()
        if result == 'ok':
            break
        else:
            print('이미 존재하는 유저명입니다.')

    # 로그인 성공시
    # 수신 대기 스레드
    receive_thread = Thread(target=receive_handler, args=(client_socket,))
    receive_thread.daemon = True
    receive_thread.start()

    # 송신 대기 스레드
    send_thread = Thread(target=send_handler, args=(client_socket,))
    send_thread.daemon = True
    send_thread.start()

    # 수신 대기 스레드의 종료를 대기
    # 송신 대기 스레드가 종료시 소켓이 닫혀 수신 대기 스레드도 자동종료
    # 수신 대기 스레드가 종료시 메인 프로세스가 종료되며 송신 대기 스레드도 자동종료
    receive_thread.join()
  • 서버의 host 주소와 port 번호를 입력받아 연결요청

  • 연결 성공시 유저명을 입력하여 채팅방 입장 시도

  • 중복되는 유저명일 경우 재입력

  • 입장 성공시 수신 대기 스레드와 송신 대기 스레드를 daemon으로 생성

  • 수신 대기 스레드는 서버와의 연결이 끊어지기 전까지 서버로부터 전송되는 메시지를 받아 출력

  • 송신 대기 스레드는 입력된 메시지를 서버에 전송 종료 명령어가 입력될 경우 반복문을 빠져나와 소켓을 close

 

'CS > 네트워크' 카테고리의 다른 글

#6 Transport Layer  (0) 2021.11.16
#5 Application Layer 2 - HTTP  (0) 2021.11.01
#3 Application Layer 1 - 소켓(Socket)  (0) 2021.10.28
#2 OSI 7계층  (0) 2021.10.28
#1 네트워크 구성 2 - Network Core  (0) 2021.10.28

프로그램의 메인화면이 될 Central Widget은 작성할 문서의 종류를 탭(tab) 형식으로 보여주도록 할 것이다.

문서의 종류는 일일 정산서 계산서(DayCal), 대차대조표(BalancedSheet), 수협 입금(SH) 의 세 종류이다.

 

1. CentralWidget

from PySide6.QtCore import QDateTime, QTimer
from PySide6.QtGui import QIcon, QAction
from PySide6.QtWidgets import QMainWindow, QWidget, QLabel, QGridLayout

from controller.config_manager import set_geometry, get_geometry
from widgets.docs_window import DocTab


class MainWindow(QMainWindow):
    # 생성자
    def __init__(self):
        super().__init__()
        self.init_ui()

    # ui 초기화
    def init_ui(self):
    
        ``` 생략 ```
        
        # 중앙 위젯
        central_widget = QWidget()
        central_widget.setStyleSheet("background-color: #FFFFFF")

        # 그리드 레이아웃
        grid = QGridLayout()

        # 탭 위젯 추가
        grid.addWidget(DocTab(), 0, 0)

        # 중앙 위젯에 그리드 레이아웃 적용
        central_widget.setLayout(grid)

        # 앱의 Central Widget 에 central_widget 설정
        self.setCentralWidget(central_widget)
    
        ``` 생략 ```
        
        # 윈도우를 화면에 띄운다
        self.show()
  • 빈 위젯(QWidget) central_widget 생성

  • 세 종류의 문서를 탭 형태로 표현하는 DocTab 위젯을 그리드 레이아웃에 추가

  • central_widget에 그리드 레이아웃을 적용
  • setCentralWidget 메소드를 호출하여 MainWindow 의 Central Widget으로 central_widget을 설정

 

 

2. DocTab

세 종류의 문서를 탭 형태로 나타내기 위해 QTabWidget 을 상속하는 DocTab 위젯을 생성한다.

 

# 문서 탭 위젯
class DocTab(QTabWidget):
    def __init__(self):
        super().__init__()
        self.init_ui()

    def init_ui(self):
        tab1 = DayCal()
        tab2 = BalancedSheet()
        tab3 = SH()

        self.addTab(tab1, '일일정산서 계산서')
        self.addTab(tab2, '대차대조표')
        self.addTab(tab3, '수협 입금')
  • widgets/docs_window.py 에  QTabWidget을 상속하는 DocTab 클래스를 정의

  • 일일 정산서 계산서(DayCal), 대차대조표(BalancedSheet), 수협 입금(SH) 세 종류의 위젯을 각각 탭으로 추가

 

 

3. 일일 정산서 계산서(DayCal)

일일 정산서 계산서를 작성하기 위한 DayCal 위젯을 생성한다.

 

# 일일 정산서 계산서 위젯
class DayCal(QWidget):
    # 생성자
    def __init__(self):
        super().__init__()
        self.init_ui()

    # ui 초기화
    def init_ui(self):
        # 화주 목록
        owners = ['화주1', '화주2', '화주3']
        input_table = QTableWidget()
        input_table.setColumnCount(len(owners))
        input_table.setHorizontalHeaderLabels(owners)
        input_table.setRowCount(19)
        input_table.setVerticalHeaderLabels([
            '강동총금액', '강동운임', '강동하차비', '강동수수료 4%', '공제후금액', '',
            '중매수수료 5%', '화주운임', '화주하차비', '상장수수료 4%', '강동선지급금', '공제합계', '선지급금포함 공제합계', '',
            '경매 사무실입금', '가라경매 강동입금', '직접지출', '우리경매', '강동사입'
                                       ])

        # 그리드 레이아웃
        grid = QGridLayout()

        # 테이블위젯 추가
        grid.addWidget(input_table, 0, 0)

        # 레이아웃 세팅
        self.setLayout(grid)
  • widgets/docs_window.py에 QWidget을 상속하는 DayCal 클래스 생성
  • 화주 목록 owners 를 예시로 작성

  • 문서 작성을 위한 테이블 위젯 input_table 을 생성, 세로방향 헤더에는 입력받을 항목들이,
    가로방향 헤더에는 화주 목록의 화주들의 이름이 오도록 한다.

  • 그리드 레이아웃에 input_table을 추가
  • DayCal 에 그리드 레이아웃 적용

 

 

4. 대차대조표(BalancedSheet) / 수협입금(SH)

# 대차대조표 위젯
class BalancedSheet(QWidget):
    pass


# 수협 입금 위젯
class SH(QWidget):
    pass

이 두 종류의 문서는 추후에 기능을 추가할 예정이기에 우선은 구현을 생략한다.

 

 

 

 

이제 애플리케이션의 메인화면에 위와 같이 탭 형태로 세 종류의 문서를 작성할 수 있다.

간략하게 구현해둔 일일정산서 계산서 탭에는 테이블이 표시되는 것을 볼 수 있다.

 

 

 

 

'개인 프로젝트 > Accounting Program' 카테고리의 다른 글

#14 화주 추가  (0) 2021.11.01
#13 데이터베이스 연결  (0) 2021.10.30
#11 종료시 위치 기억  (0) 2021.10.26
#10 프로젝트 구조 변경  (0) 2021.10.20
#9 레이아웃(Layout)  (0) 2021.10.18

1. 소켓(Socket)

 소켓은 응용 계층에서 프로세스간 통신을 쉽게 행하기 위한 창구의 역할을 한다. 사용자는 소켓이라는 형태로

통신할 대상과의 연결을 확립하고 소켓과의 송/수신으로 대상과의 송/수신을 하는 것과 마찬가지인 결과를

얻게된다. 즉, 소켓을 사용하는 법만 알면 하위 계층에 대한 지식이 없더라도 쉽게 통신이 가능해지는 것이다. 

서버와 클라이언트가 특정 port 를 통해 실시간 양방향 통신을 하는 방식이기에 실시간 통신이 필요한

스트리밍이나 채팅 등의 서비스에 사용된다.

 

2. 소켓의 종류

① 스트림(TCP 소켓)

출처: https://www.cs.dartmouth.edu/~campbell/cs60/socketprogramming.html

  • 서버측 흐름
    1. 소켓 생성(socket)
    2. 포트번호 설정(bind)
    3. 수신 대기열 생성(listen)
    4. 연결 대기(accept) - 연결 요청이 들어올 때 까지 block 상태
    5. 연결 요청이 들어오면 handshaking 과정을 거쳐 연결수립
    6. 이후 양방향 통신 가능
    7. 연결 종료 요청이 들어오면 종료과정을 거쳐 연결종료(close)

  • 클라이언트측 흐름
    1. 소켓 생성(socket)
    2. 연결 요청(connect) - handshaking 과정을 거쳐 연결수립
    3. 이후 양방향 통신 가능
    4. 통신 종료시 종료 요청(close) - 종료 과정을 거쳐 연결종료
                        
  • 연결 지향성 양방향 통신을 위한 소켓

  • 데이터를 스트림 형태로 전송하기에 데이터간의 경계가 없음

  • 데이터의 순서유지와 무결성 보장

  • 대량의 데이터를 안전하게 주고받기 위해 사용

 

② 데이터그램(UDP 소켓)

출처: https://www.cs.dartmouth.edu/~campbell/cs60/socketprogramming.html

  • 서버측 흐름
    1. 소켓 생성(socket)
    2. 포트번호 설정(bind)
    3. 요청 대기(recvfrom) - request 데이터그램을 수신할 때 까지 대기
    4. 요청이 들어오면 응답(sendto)

  • 클라이언트측 흐름
    1. 소켓 생성(socket)
    2. 요청(sendTo)
    3. 응답 대기(recvfrom)
    4. 통신 완료시 연결 종료(close)

  • 비연결형 통신을 위한 소켓

  • 한번의 전송으로 보낼 수 있는 데이터의 크기에 제한이 있다.

  • 데이터의 순서유지와 무결성을 보장하지 않음

  • 신뢰성보다는 속도가 중시되는 경우, 작은 데이터를 자주 보내야하는 경우 등에 사용(RTP 등)

 

'CS > 네트워크' 카테고리의 다른 글

#5 Application Layer 2 - HTTP  (0) 2021.11.01
#4 Socket Programming  (0) 2021.10.29
#2 OSI 7계층  (0) 2021.10.28
#1 네트워크 구성 2 - Network Core  (0) 2021.10.28
#0 네트워크의 구성 1 - Network Edge  (0) 2021.05.29

1. OSI 7계층(OSI 7 Layer)

OSI 7계층은 네트워크 프로토콜 디자인과 통신을 위와 같이 일곱 계층으로 나누어 표현하는 모델이다.

각 계층은 자신의 하위 계층이 제공하는 기능만을 사용하며 자신의 상위 계층에 기능을 제공한다.  

이러한 방식으로 네트워크를 구성함으로써 네트워크상에서 통신이 일어나는 흐름을 알아보기 쉽고

유지/보수 시에 문제가 발생한 기능에 해당하는 계층만을 수정하는 것으로 문제를 해결할 수 있게 된다.

 

 

2. 개요

 ① 물리 계층(Physical Layer)

  • 하드웨어 레벨에서의 데이터 전송이 발생하는 계층

  • 전기신호의 형태로 이진 데이터(Binary Data)가 전달된다.

  • 리피터, 허브, 케이블 등이 이 계층에 속하는 장비이다.

  • 데이터 단위는 bit 이다

 

 ② 데이터 링크 계층(Data Link Layer):

  • 직접 연결된 기기간에 물리 주소(MAC Address)를 통해 데이터를 전달하는 계층

  • 물리 계층에서의 전송중에 발생하는 오류를 검출하고 수정하는 기능과 흐름제어 기능 등을 제공한다

  • 스위치, 브릿지 등이 이 계층에 속하는 장비이다.

  • 데이터 단위는 frame 이다.

 

 ③ 네트워크 계층(Network Layer)

  • 데이터가 최종 목적지까지 최단경로로 도달할 수 있도록 패킷 포워딩(Packet Forwarding)을
    담당하는 계층

  • IP(Internet Protocol) 가 이 계층에 속하는 프로토콜이다. (IP 주소를 통해 데이터를 전달한다.)
  • 앞서 Network Core에 대한 설명을 했을 때 언급했던 라우터(Router)가 이 계층에 속하는 장비이다.

  • 라우터는 데이터가 목적지까지 도달하기 위한 최단경로를 계산하여 패킷을 전송한다. 이 때, 네트워크의
    상태를 파악하여 패킷 전송량을 조절하는 혼잡 제어(Congestion Control) 또한 수행한다.

  • 데이터 단위는 packet 이다.

 

 ④ 전송 계층(Tranport Layer)

  • 네트워크의 종단의 사용자간에 신뢰성있는 데이터 통신이 가능하도록 해주는 계층

  • Port 번호를 통해 데이터를 전달한다.
  • 프로토콜에 따라 오류 검출, 재전송, 흐름 제어, 중복 검사 등을 수행한다.
  • TCP / UDP 가 이 계층에 속하는 프로토콜이다.

  • 데이터 단위는 segment 이다.

 

 ⑤ 세션 계층(Session Layer)

  • 실제로 통신을 하기 위한 논리적인 연결 상태, 즉 세션(session)을 연결하는 계층

  • 세션을 연결, 관리, 종료하는 기능을 수행한다. 이러한 작업은 운영체제 레벨에서 수행된다.

 

 ⑥ 표현 계층(Presentation Layer)

  • 응용 계층에서 사용하는 표현 형식과 전송을 위한 공통적인 표현 형식 사이의 변환을 위한 계층

  • 전송받은 데이터를 응용 계층이 이해할 수 있는 형태로, 전송할 데이터를 전송을 위한 형태로 번역한다.
  • 파일의 확장자나 문서의 인코딩 등이 이에 속한다.

 

 ⑦ 응용 계층(Application Layer)

  • 전송할 데이터를 사용자에게 입력받거나 전송받은 데이터를 실제로 사용자에게 보여주는 계층
  • HTTP, FTP, SMTP 등이 이 계층에 속하는 프로토콜이다.
  • 위의 프로토콜을 처리하기 위한 응용 프로그램으로 브라우저 등이 있다.

 

 

실질적인 통신에는 OSI 7 계층 모델보다는 5, 6, 7 계층을 모두 묶어 응용 계층으로

분류하는 TCP/IP 5계층 모델을 사용하게된다.

'CS > 네트워크' 카테고리의 다른 글

#5 Application Layer 2 - HTTP  (0) 2021.11.01
#4 Socket Programming  (0) 2021.10.29
#3 Application Layer 1 - 소켓(Socket)  (0) 2021.10.28
#1 네트워크 구성 2 - Network Core  (0) 2021.10.28
#0 네트워크의 구성 1 - Network Edge  (0) 2021.05.29

네트워크의 종단 사이를 연결하는 부분. 라우터 등의 네트워크 종단 사이를 연결하는

중계기들이 여기에 속한다. 

1. Switching 

 ① 회선 교환 방식(Circuit Switching)

  • 송수신 단말 사이에 데이터를 전송할 때마다 전용 회선을 예약하여 데이터를 교환하는 방식

  • 회선 연결, 데이터 전송, 연결 해제의 3단계로 이루어진다

  • 대용량의 데이터, 연속적인 데이터를 빠르고 안정적으로 처리 가능 (팩스, 전화 등에 적합)

  • 회선 사용중에 다른 사용자는 해당 회선을 사용할 수 없음

 

 ② 패킷 교환 방식(Packet Switching)

  • 회선 교환 방식과 달리 두 단말 사이에 전용회선을 예약하지 않음

  • 전송할 데이터를 패킷(packet)의 형태로 묶어 전달한다.

  • 패킷에 들어있는 정보를 라우터(Router)가 처리하여 패킷이 전달되어야 할 목적 단말로
    최적의 경로를 계산하여 전송한다.  이 때 최적경로는 단순히 거리 뿐 아니라 망의 혼잡도,
    연결 상태 등 다양한 조건을 고려하여 계산된다.

  • 전용회선 예약이 없기에 망을 낭비하지 않음

  • 패킷에 에러 발생 시 패킷 내의 데이터를 통해 검출, 재송신 가능

  • 회선에 문제 발생 시 다른 경로를 즉각적으로 사용 가능

  • 최적경로 탐색을 위한 시간이 소요됨
  • 전송량이 증가할 경우 지연율이 급상승

  • 패킷에 추가되는 헤더로 인해 오버헤드 발생
  • 인터넷은 패킷 교환 방식을 사용

 

 

2. 패킷 교환 방식(Packet Switching)의 문제와 해결

 ① 패킷 지연(Packet Delay)

  • Node Processing Delay(노드 처리 지연) : 데이터 패킷 헤더의 처리, 비트 오류 검사 등에 걸리는 시간

  • Queing Delay(대기 지연) : 라우터의 처리속도보다 데이터 전송량이 클 경우 대기열에서 기다리는 시간

  • Transmission Delay(전송 지연) : 데이터의 첫 번째 비트가 전송된 후 마지막 비트가 전송되는 순간까지의 시간
    • R = 연결 대역폭(Link Bandwidth) - bps
    • L = 패킷 길이(Packet Length) - bits
    • Transmission Delay = L / R

  • Propagation Delay(전파 지연) : 데이터가 물리적인 회선을 통해 전달되는 데 걸리는 시간
    • d = 물리적인 회선의 길이(length of physical link)
    • s = 전파의 속도(~2*10^8 m/s)
    • Propagation Delay = d/s

 

  • 패킷 지연의 개선 방법
    • Node Processing Delay : 라우터의 성능 개선
    • Transmission Delay : 회선의 성능 개선(연결 대역폭 증가)
    • Queing Delay, Propagation Delay : 현재 시스템으로는 사실상 줄일 수 없음

 

 ② 패킷 유실(Packet Loss)

  • 대부분의 패킷 유실은 라우터의 대기열이 포화된 상태에서 새로운 패킷이 추가되어 발생

  • TCP를 사용한 통신의 경우 이러한 패킷 유실을 감지하여 송신 단말측에서 재전송하는 것으로 해결

  • 유실 직전의 라우터에서 재전송할 수 있다면 좋겠지만 라우터의 부하가 너무 커지기에 현실적으로 어려움

'CS > 네트워크' 카테고리의 다른 글

#5 Application Layer 2 - HTTP  (0) 2021.11.01
#4 Socket Programming  (0) 2021.10.29
#3 Application Layer 1 - 소켓(Socket)  (0) 2021.10.28
#2 OSI 7계층  (0) 2021.10.28
#0 네트워크의 구성 1 - Network Edge  (0) 2021.05.29

이번에는 프로그램을 종료하는 순간의 윈도우의 위치와 크기를 기억하고 다음에 다시 실행할때 그 위치와 크기로 실행되는

기능을 구현해본다.

 

1. widgets/main_window.py 수정

 메인 윈도우가 종료되는 시점에 호출되는 closeEvent 메소드를 오버라이딩하여 윈도우의 위치와 크기를 설정파일 config.json에
저장하고 실행시에는 설정파일의 내용을 읽어 메인 윈도우의 초기 위치와 크기값을 지정하는 방식으로 기능을 구현하려 한다.

설정파일이 없다면 디폴트 설정을 사용하여 실행하고 종료시에 설정파일을 생성하여 저장하도록 할 것이다.

 

``` 생략 ``` 

from controller.config_manager import set_geometry, get_geometry


class MainWindow(QMainWindow):

    ``` 생략 ```
    
        # 윈도우 위치, 크기 설정
        geometry = get_geometry()
        if not geometry:
            self.init_geometry()
        else:
            self.setGeometry(*geometry)
            
    ``` 생략 ```
    
    # 윈도우 크기 초기설정
    def init_geometry(self):
        # 윈도우 초기 사이즈 지정
        self.resize(1000, 800)

        # 모니터 화면의 중앙 위치 정보
        center_pos = self.screen().availableGeometry().center()

        # 윈도우의 중앙위치를 모니터 화면의 중앙으로 이동
        self.frameGeometry().moveCenter(center_pos)

    # 종료시 윈도우의 위치와 크기를 설정파일에 저장
    def closeEvent(self, event):
        QMainWindow.closeEvent(self, event)
        set_geometry(self.geometry())

설정파일이 없을 경우 윈도우의 크기와 위치를 디폴트값으로 설정하는 init_geometry 메소드를 정의하고 controller 디렉토리의

config_manage.py 에 정의된 set_geometry 함수가 호출되도록 closeEvent 를 오버라이딩 해준다. 이제 프로그램이 실행될 때는

get_geometry 함수를 호출하여 설정파일의 내용을 읽어 만약 설정파일이 없다면 init_geometry를 호출하여 디폴트값을, 파일이

존재한다면 설정파일의 값을 사용하여 윈도우를 초기화하고 종료될 때는 set_geometry 함수를 호출하여 종료시점의 윈도우의

위치와 크기를 설정파일에 저장하게 된다.

 

 

2. controller/config_manager.py 작성

 이제 main_window.py 에서 사용한 set_geometry, get_geometry 함수를 실제로 구현할 차례이다.

 

  
from json import dump, load, JSONDecodeError


# config 파일의 내용을 불러와 geometry 값을 수정하여 저장
def set_geometry(geometry):
    with open('config.json', 'w+') as config_file:
        try:
            config = load(config_file)
        except JSONDecodeError:
            config = {}

        config['geometry'] = {
            'x': geometry.x(),
            'y': geometry.y(),
            'height': geometry.height(),
            'width': geometry.width()
        }
        dump(config, config_file, indent=4)


# config 파일의 내용을 불러와 geometry 값을 읽어 리스트형태로 반환
def get_geometry():
    try:
        with open('config.json', 'r') as config_file:
            try:
                config = load(config_file)
            except JSONDecodeError:
                return None
    except FileNotFoundError:
        return None
    return [config['geometry'][key] for key in ['x', 'y', 'width', 'height']]

controller 디렉토리의 config_manager.py 를 생성하여 위와 같이 작성한다. 함수의 내용은 간단하다.

set_geometry 는 MainWindow 가 호출시에 넘겨준 geometry 데이터에서 x좌표 y좌표, 높이, 너비를

config.json 파일에 key : value 의 형태로 작성하고 get_geometry 는 반대로 config.json 파일에서 읽어온

geometry 데이터를 리스트의 형태로 반환한다.  만약 config.json 파일이 존재하지 않거나 잘못된 형식의

json 파일이라면 None을 반환한다.

 

 

※ 여기까지의 작업으로도 위치, 크기 기억 기능은 정상적으로 작동하지만 최대화한 이후 종료할 경우

     다시 프로그램을 실행하여 최소화 하더라도 최대화 상태의 크기와 위치가 되어버리는 문제가 발생한다.

     이부분은 현재 해결중이다.

 

3. 최대화 상태에서 종료할 경우의 문제 해결

	``` 생략 ```

        # 윈도우 위치, 크기 설정
        geometry = get_geometry()
        if not geometry:
            self.init_geometry()
        else:
            self.setGeometry(geometry[1], geometry[2], geometry[3], geometry[4])
            if geometry[0]:
                self.showMaximized()

        # 윈도우를 화면에 띄운다
        self.show()

    ``` 생략 ```

    # 종료시 윈도우의 위치와 크기를 설정파일에 저장
    def closeEvent(self, event):
        QMainWindow.closeEvent(self, event)
        set_geometry(self.normalGeometry(), self.isMaximized())

 생각보다 간단하게 문제를 해결할 수 있었다.  set_geometry 를 호출할 때 geometry 대신 normalGeomtery 데이터를

넘겨주고 최대화 여부를 함께 넘겨주어 config 파일에 저장하는 것으로 실행시에도 normalGeomtery 값으로 윈도우를

초기화하고 최대화 여부가 True라면 showMaximized 를 호출하여 최대화된 상태로 보여주는 것으로 해결하였다.

 

from json import dump, load, JSONDecodeError


# config 파일의 내용을 불러와 geometry 값을 수정하여 저장
def set_geometry(geometry, is_maximized):
    with open('config.json', 'w+') as config_file:
        try:
            config = load(config_file)
        except JSONDecodeError:
            config = {}

        config['geometry'] = {
            'max': is_maximized,
            'x': geometry.x(),
            'y': geometry.y(),
            'height': geometry.height(),
            'width': geometry.width()
        }
        dump(config, config_file, indent=4)


# config 파일의 내용을 불러와 geometry 값을 읽어 리스트형태로 반환
def get_geometry():
    try:
        with open('config.json', 'r') as config_file:
            try:
                config = load(config_file)
            except JSONDecodeError:
                return None
    except FileNotFoundError:
        return None
    return [config['geometry'][key] for key in ['max', 'x', 'y', 'width', 'height']]

config_manager 파일도 위와 같이 수정하여 최대화 여부를 geometry 정보에 추가하였다.

이제 최대화 상태로 종료하더라도 일반 상태의 위치와 크기를 기억할 수 있게 되었다.

'개인 프로젝트 > Accounting Program' 카테고리의 다른 글

#13 데이터베이스 연결  (0) 2021.10.30
#12 메인 화면 구성  (0) 2021.10.28
#10 프로젝트 구조 변경  (0) 2021.10.20
#9 레이아웃(Layout)  (0) 2021.10.18
#8 스타일시트  (0) 2021.10.18

1. 해싱(Hashing)

해싱은 데이터를 키:값의 쌍으로 구성하여 배열에서 인덱스로 데이터를 참조하듯 키로 데이터에 바로 접근할 수 있도록 하기위해

고안된 방식이다.  해시 함수(Hash Function)를 사용하여 키 값을 특정한 길이의 해시값으로 변환하고 이 해시값에 해당하는

위치에 데이터를 저장하는 것으로 삽입, 삭제, 탐색에 O(1)에 가까운 성능을 보이도록 한다.  Python의 dictionary 나 Java의
HashMap, HashTable 과 같이 대부분의 언어에서 내장 라이브러리로 지원하며 굉장히 자주 사용되는 유용한 방식이다. 

 

 

2. 용어

  • 버킷(Bucket) : 해시 함수로 얻은 해시 값을 주소로 하는 저장 공간.  해시 방식은 미리 지정된 갯수만큼의 버킷을 만들어두고
                                  데이터를 저장해나간다. 

  • 슬롯(Slot) : 버킷이 데이터 하나를 저장하기 위해 사용하는 공간. 

  • 충돌(Collision) : 서로 다른 키 값에 대해 해시 함수로 얻은 해시 값이 서로 동일한 경우, 즉 같은 버킷에 다수의 데이터를
                                      저장해야하는 상황을 의미한다.
  • 클러스터링(Clustering) : 연속된 주소를 가진 버킷에 데이터가 밀집되는 현상을 의미한다.
  • 부하율(Load Factor) : 전체 버킷 중 사용중인 버킷의 비율을 의미한다. 

 

 

3. 해싱의 특징

  • 삽입, 삭제, 탐색에 평균적으로 O(1)의 성능을 보인다.

  • 해시 함수의 특성상 언젠가 충돌이 반드시 발생하며 그럴수록 성능이 저하되기 때문에 해시 함수가 얼마나
    충돌이 일어나지 않도록 해시 값을 생성하느냐에 따라 성능이 크게 좌우된다.

 

 

4. 충돌(Collision) 의 처리

 ① 체이닝(Chaining) 기법

  • 같은 해시 값을 가지는, 즉 같은 버킷에 저장해야할 데이터들을 연결 리스트의 형태로 이어붙이는 방식

  • 비교적 구현이 간단하고 연결리스트를 사용하기 때문에 슬롯 갯수의 제약이 없다.

  • 클러스터링에 영향을 거의 받지 않기 때문에 해시 함수를 선정할 때 충돌의 최소화만을 중점적으로 고려할 수 있다.

  • 충돌이 많이 발생할 경우 최악의 경우 O(N) 수준까지 성능이 저하될 수 있다.
     
  • 하나의 버킷의 슬롯 갯수가 일정 이상으로 늘어나면 연결 리스트 대신 이진 트리를 사용하여 검색효율을 높이기도 한다.

  • 부하율이 높아져도 성능 저하가 선형적으로 발생하기 때문에 높은 부하율이 예상될수록 개방주소기법에 비해 성능이 좋다.

  • 처음에 얻은 해시주소 내에서 문제를 해결하기 때문에 폐쇄 해싱(Closed Hashing)이라고도 한다.

 

 ② 개방주소(Open  Addressing) 기법

  • 충돌이 발생할 경우 다른 비어있는 버킷을 찾아 데이터를 삽입하는 방식

  • 체이닝 기법과 달리 추가적인 메모리나 외부 자료구조에서의 작업을 필요로하지 않는다.

  • 연결리스트나 트리 등 외부 자료구조를 사용하지 않기 때문에 삽입/삭제의 오버헤드가 적어 저장할 데이터가  적다면
    체이닝 기법보다 좋은 성능을 보인다.

  • 저장해야할 데이터가 많아져서 부하율이 일정 이상으로 증가하면 체이닝에 비해 성능이 급격히 저하된다.
  • 비어있는 버킷을 찾는 방식으로 선형 탐색, 제곱 탐색, 이중 해싱이 있다. 

  • 선형 탐색은 말그대로 선형적으로 다음 버킷을 참조해나가며 빈 버킷을 찾는 방식이다.

  • 제곱 탐색은 처음 얻은 해시 값에서 제곱수만큼씩 증가해나가며 빈 버킷을 찾는 방식이다.
  • 이중 해싱은 처음 얻은 해시 값이 충돌을 일으킬 경우 2차 해시 함수를 사용하여 다시한번 해시 값을 얻는 방식이다.
    가장 많은 연산을 필요로하지만 클러스터링의 영향을 거의 받지 않는다.

 ※ 클러스터링에 취약하다는 것은 반대로 참조 지역성(Locality of Reference)이 높다는 의미가 되기 때문에
      캐싱 성능이 좋아진다. 그렇기 때문에 선형탐색 -> 제곱 탐색 -> 이중 해싱 순으로 클러스터링 방지는 잘 되지만
      참조 지역성이 낮아져 캐싱 성능은 떨어진다. 체이닝 기법의 경우에도 클러스터링의 영향을 거의 받지 않기 때문에
      참조 지역성이 낮아 선형탐색을 통한 개방주소 기법에 비해 캐싱 성능이 떨어진다.

 

 

5. 리사이징(Resizing) & 재해싱(Rehashing)

  • 부하율이 높아져 성능이 저하되는 것을 방지하기 위해 기존보다 더 많은 버킷을 할당한 뒤 기존에 저장된 데이터들을
    새로운 공간간에 다시해싱하는 작업 

  • 개방주소법의 경우 삭제 시 발생하는 더미 데이터 등으로 인한 성능저하 문제도 있기 때문에 주기적인 재해싱을 해줘야
    성능을 유지할 수 있다.

 

1. ADT 작성

Trie:
    Datas:
        __root : 트리의 루트노드
        __size : 트리의 크기

    Operations:
        insert(string) : 문자열 string 을 트라이에 삽입
        find(string) : 문자열 string이 트라이에 들어있다면 True, 아니라면 False 반환
        
Trie.__Node:
    Datas:
    	is_term : 해당 노드가 문자열의 마지막 문자를 나타내는지 여부
        next : 연결된 다음 문자 노드 리스트.  next[ord(key) - ord('a')] 가 None 이 아니라면
               다음 문자가 key인 문자열이 Trie 내에 존재한다는 의미가 된다.

    Operations:
    	get(key) : 문자 key 에 해당하는 다음 문자 노드를 반환
        set(key) : 문자 key 에 해당하는 다음 문자 노드를 설정

 

 

2. 구현 

class Trie:
    # 트라이의 노드 클래스
    class __Node:
        def __init__(self, is_term=False):
            self.next = [None] * 26
            self.is_term = is_term

        # key 에 해당하는 다음 문자 노드를 반환
        def get(self, key):
            return self.next[ord(key) - ord('a')]

        # key 에 해당하는 다음 문자 노드를 설정
        def set(self, key, node):
            self.next[ord(key) - ord('a')] = node

    def __init__(self):
        # 루트 노드
        self.__root = self.__Node()
        
        # 트라이의 크기
        self.__size = 0

    def __len__(self):
        return self.__size

    # 문자열 삽입
    def insert(self, string: str):
        # 루트 노드에서 탐색 시작
        cur = self.__root

        # 삽입할 문자열의 모든 문자를 소문자로 변환
        for c in string.lower():
            nxt = cur.get(c)
            # 문자 c에 해당하는 노드가 없다면 생성
            if not nxt:
                nxt = self.__Node()
                cur.set(c, nxt)

            # 문자 c에 해당하는 노드로 이동
            cur = nxt

        # 기존에 없었던 문자열이라면
        if not cur.is_term:
            # 문자열의 마지막 문자에 해당하는 노드에 is_term 값을 True 로 설정
            cur.is_term = True
        
            # 트리의 크기 1 증가
            self.__size += 1

    # 문자열 탐색
    def find(self, string):
        return True if self.__search(string) else False

    # 문자열의 마지막 문자에 해당하는 노드를 찾아 반환
    def __search(self, string: str):
        cur = self.__root
        for c in string.lower():
            cur = cur.get(c)
            if not cur:
                return None
        if cur.is_term:
            return cur
        return None

 

1. 트라이(Trie)

 트라이(Trie)는 문자열의 빠른 탐색을 위해 고안된 트리이다. 하나의 노드가 하나의 문자를 나타내도록 하여

문자열을 저장해나가는 것으로 검색할 문자열의 길이가 N 일 때, O(N)의 복잡도로 탐색을 마칠 수 있다.

하나의 문자열의 끝임을 표시하기 위해 문자열의 마지막 문자인 노드를 식별할 수 있도록 한다.

위 그림의 트라이는 문자열의 마지막 문자인 노드를 주황색으로 표시한 것이다.  위 트라이에 저장된 문자열은

be, bee, bean, app, apple, apart 의 6개가 된다.

 

 

2. 특징

  • 길의 N의 문자열을 삽입할 경우 O(N)의 시간복잡도로 삽입 가능

  • 길이 N의 문자열을 탐색할 경우 O(N)의 시간복잡도로 탐색 가능

  • 저장된 문자열 중 지금까지 입력한 문자열로 시작하는 모든 문자열을 파악할 수 있기 때문에 
    자동완성 기능의 구현에 사용됨

트라이의 삽입/탐색 연산은 굉장히 간단하기 때문에 구현 파트에서 주석으로 간단히 설명하고 넘어가기로 한다.

'CS > 자료구조' 카테고리의 다른 글

#14 해싱(Hashing)  (0) 2021.10.26
#13 트라이(Trie) 구현  (0) 2021.10.25
#11 트리(Tree) 5 - B-Tree  (0) 2021.10.18
#10 레드블랙 트리(Red-Black Tree) 구현  (0) 2021.10.14
#9 트리(Tree) 4 - 레드블랙 트리(Red-Black Tree)  (0) 2021.10.08

+ Recent posts