Develop

Solidity 치트시트

https://www.youtube.com/watch?v=QYeBPgqKgIc&list=PLJQKWHLhBrxI43w0DU4uQrhWv4Pm1OFlx 1강: Hello Solidity & 스마트 컨트랙트 기초솔리디티(Solidity): 이더리움 블록체인에서 스마트 컨트랙트(Smart Contract)를 개발하기 위한 언어.스마트 컨트랙트: 미리 정의된 조건이 충족되면 블록체인 내에서 자동으로 실행되는 프로그램.개발 환경: 온라인 IDE인 Remix(리믹스)를 주로 사용함. https://remix.ethereum.org/기본 구조라이선스 명시: 파일 최상단에 SPDX-License-Identifier를 반드시 명시해야 함.버전 설정: pragma solidity를 통해 컴파일러 버전을 지정.컨트랙트 정의..

Solidity 치트시트

728x90
1강: Hello Solidity & 스마트 컨트랙트 기초
  • 솔리디티(Solidity): 이더리움 블록체인에서 스마트 컨트랙트(Smart Contract)를 개발하기 위한 언어.
  • 스마트 컨트랙트: 미리 정의된 조건이 충족되면 블록체인 내에서 자동으로 실행되는 프로그램.
  • 개발 환경: 온라인 IDE인 Remix(리믹스)를 주로 사용함. https://remix.ethereum.org/
  • 기본 구조
    • 라이선스 명시: 파일 최상단에 SPDX-License-Identifier를 반드시 명시해야 함.
    • 버전 설정: pragma solidity를 통해 컴파일러 버전을 지정.
    • 컨트랙트 정의: contract 키워드 뒤에 이름을 붙여 시작.
    • 문장 종료: 모든 문장 끝에는 세미콜론(;)을 붙여야 함.

 

2강: 데이터 타입(Data Type)
  • 주요 타입
    • bool: true 또는 false 값을 가짐.
    • bytes: 1~32바이트까지 지정 가능하며, bytes만 쓰면 스트링을 바이트화하여 유동적으로 저장함.
    • address: 계좌 주소 또는 스마트 컨트랙트 배포 주소를 의미함 (길이 20바이트).
    • int/uint: 정수형. int는 음수 포함, uint는 부호 없는 정수(0부터 시작)로 실무에서 더 자주 쓰임.
  • 주의사항: uint8과 같이 범위를 지정했을 때 해당 범위를 넘어가면 에러가 발생함.

 

3강: 이더(Ether) 단위와 가스(Gas)
  • 이더 단위:
    • 1 Ether = 10^9Gwei = wei.
    • Gwei: 가스비를 낼 때 주로 사용하는 단위 ( Wei).
  • 가스(Gas): 스마트 컨트랙트를 실행할 때 지불하는 비용.
    • 코드가 길거나 복잡할수록 더 많은 가스가 소비됨.
    • 보안: 가스 비용은 해커가 무차별적으로 네트워크를 공격(DDoS)하는 것을 방지하는 역할도 함.

 

4강: 함수(Function) 정의
  • 기본 구조: function name(parameter) access_modifier [view|pure] returns(type) { ... }.
    function retrieve() public view returns (uint256){
        return number;
    }

 

5강: 접근 제한자(Visibility Specifiers)
  1. public: 어디서든(내부, 외부, 상속 컨트랙트) 접근 가능.
  2. private: 오직 해당 컨트랙트 내부에서만 접근 가능 (상속받아도 접근 불가).
  3. external: 오직 외부에서만 호출 가능 (자기 컨트랙트 내 호출 불가).
  4. internal: 내부와 상속받은 컨트랙트에서 접근 가능.

 

6강: View와 Pure 함수
  • view: 함수 밖의 변수(상태 변수)를 읽을 수 있지만 변경은 불가능할 때 사용.
  • pure: 함수 밖의 변수를 읽지도 못하고 변경도 불가능할 때 사용. 오직 함수 내부의 값만 활용함.
  • 상태 변수를 변경하는 경우에는 두 키워드 모두 쓰지 않음.
uint256 public a = 1;

function read_a() public view returns(uint256){
	return a+2;
}

function read_a2() public pure returns(uint256){
	uint256 b = 1;
    return b+2;
}

function read_a2() public returns(unit256){
	a = 13;
    return a;
}

 

7강: 저장 영역(Storage Areas) 및 String
  • 저장 영역 4가지:
    • storage: 블록체인에 영구적으로 기록됨. 가스비가 가장 비쌈 : 대부분의 변수, 함수들이 저장
    • memory: 함수 실행 중에만 존재하고 사라지는 휘발성 데이터 : 함수의 파라미터, 리턴값, 레퍼런스 타입
    • calldata: 주로 external 함수의 파라미터에 사용됨.
    • stack: EVM에서 관리하는 제한적인 영역 1024MB 제한적.
  • String: 솔리디티의 기본 타입이 아닌 레퍼런스 타입이므로, 함수에서 쓸 때 memory 키워드를 함께 붙여줘야 함.

 

8강: 인스턴스(Instance)
  • 정의: 하나의 컨트랙트에서 다른 컨트랙트에 접근하기 위해 사용함.
  • 사용법: A인스턴스이름 = new A(); 와 같이 선언하여 다른 컨트랙트의 변수나 함수를 호출함.
  • 특징: 인스턴스는 원본 컨트랙트의 구조는 같지만 별개의 객체임. 따라서 인스턴스에서 값을 바꿔도 원본 컨트랙트의 값은 변하지 않음.

컨트랙트 자체를 직접 실행하면 static class 처럼 동작하는 느낌이고, 인스턴스는 자바와 같은 느낌.

 

9강: 생성자(Constructor)
  • 역할: 스마트 컨트랙트가 처음 배포되거나 인스턴스화될 때 변수값을 초기화.
  • 특징: 컨트랙트 배포 시 한 번만 실행되며, 필요한 파라미터를 배포 시점에 전달할 수 있음.
  • 가스비 고려: 컨트랙트 내에서 다른 컨트랙트를 인스턴스화하면 가스 소비가 커지므로 주의가 필요함.
contract A{
    string public name;
    uint256 public age;

    constructor(string memory _name, uint256 _age){
        name = _name;
        age = _age;
    }

    function change(string memory _name, uint256 _age) public {
        name = _name;
        age = _age;
    }
}

contract B{
    A instance = new A("jyami", 29);
    
    function change (string memory _name, uint256 _age) public {
        instance.change(_name, _age);
    }

    function get() public view returns(string memory, uint256){
        return (instance.name(), instance.age());
    }
}

 

10강~12강: 상속 (Inheritance)
  • 상속의 정의: 기존 컨트랙트의 기능을 다른 컨트랙트가 물려받는 것.
  • 오버라이딩(Overriding): 부모 컨트랙트의 함수를 자식 컨트랙트에서 재정의하여 사용하는 것.
  • 다중 상속: 두 개 이상의 컨트랙트를 동시에 상속받을 수 있음. override 뒤에 다중으로 상속받는다.
  • 상속의 부모가 되는 함수에는 kotlin의 open 처럼 virtual 을 사용해줘야한다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.34;

contract Father{
    uint256 public fatherMoney = 100;

    function getFatherName() public pure returns(string memory) {
        return "KimJung";
    }

    function getMoney() public view virtual returns(uint256){
        return fatherMoney;
    }

}

contract Mother{
    uint256 public motherMoney = 200;
    
    function getMotherName() public pure returns(string memory){
        return "Leesol";
    }

    function getMoney() public view virtual returns (uint256){
        return motherMoney;
    }
}

contract Son is Father, Mother {

    function getMoney() public view override(Father, Mother) returns(uint256){
        return motherMoney + fatherMoney;
    }

}
13강~14강: 이벤트(Event)와 Indexed
  • 이벤트(Event): 솔리디티에는 print 함수가 없는 대신, 이벤트를 통해 값을 출력하고 이를 블록 안에 저장. 블록에 저장된 데이터는 필요할 때 언제든 꺼내 쓸 수 있음.
  • indexed: 이벤트 파라미터에 사용하는 키워드로, 특정한 이벤트 값만 필터링하여 가져올 때 사용함. 예를 들어 수많은 이벤트 중 특정인의 이름이 포함된 것만 찾고 싶을 때 유용함.
contract A{

    event numberTracker(uint256 indexed num, string str);

    uint256 num;
    function pushEvent(string memory _str) public{
        emit numberTracker(num, _str);
        num++;
	}
    
}
function getEvent(){
	let events = await a.getPastEvents('numberTracker', {filter:{num[2,1]}, fromBlock: 1, toBlock: 'latest'});
}

indexed 가 붙어있지 않으면 filter를 해도 필터링이 되지않음.

 

 

15강~16강: 상속의 심화 (super & 순서)
  • super: 자식 컨트랙트에서 부모 컨트랙트의 함수를 호출할 때 사용함. 오버라이딩을 하면서 부모의 원래 기능도 함께 실행하고 싶을 때 유용함.
  • 상속 순서: 여러 컨트랙트를 상속받을 때, 맨 오른쪽(최신)에 명시된 컨트랙트가 가장 우선순위가 높으며 super는 이 최신 컨트랙트를 가리킴.

 

17강: 맵핑 (Mapping)
  • 구조: 키(Key)와 값(Value)으로 이루어진 데이터 구조 (예: mapping(uint => uint)).
  • 특징: 특정 키를 넣으면 대응하는 값을 즉시 얻을 수 있으나, 배열과 달리 길이(length)를 구할 수 없음.
contract A{

    mapping(uint256=>uint256) private ageMap;
    
    function setAge(uint256 index, uint age) public {
        ageMap[index] = age;
    }

    function getAge(uint256 index) public view returns(uint256 ){
        return ageMap[index];
    }
    
}

 

18강: 배열 (Array)
  • 기능: 값의 추가(push), 삭제(pop, delete), 길이 확인(length)이 가능함.
  • delete의 특징: 특정 인덱스의 값을 지우면 데이터는 기본값(0 등)으로 변하지만, 배열의 길이는 변하지 않음. 완전히 길이를 줄이려면 pop을 사용해야 함.
  • 가스비와 보안: 배열을 무한히 순환(loop)시키면 가스비가 폭등하거나 디도스(DDoS) 공격에 취약해질 수 있으므로 사이즈를 제한하는 것이 좋음. 그래서 보통 매핑을 많이 사용함.
contract A{

    uint256[] public ageList;

    function ageGet(uint256 _index) public view returns(uint256) {
        return ageList[_index];
    }

    function agePop() public {
        ageList.pop();
    }
        
    function ageDelete(uint256 _index) public{
        delete ageList[_index];
    }

    function ageChange(uint256 _index, uint256 _age) public {
        ageList[_index] = _age;
    }
    
}

 

19강: 맵핑과 배열 사용 시 주의점
  • 값의 캡처: 맵핑이나 배열에 값을 넣을 때, 당시의 값을 복사(Capture)하여 저장함. 따라서 원본 변수의 값이 나중에 변경되어도 맵핑이나 배열 내의 값은 자동으로 업데이트되지 않으므로 직접 갱신해줘야 함.

 

20강: 구조체 (Struct)
  • 정의: 사용자가 직접 만드는 커스텀 데이터 타입. 여러 종류의 변수(uint, string 등)를 하나의 그룹으로 묶어 관리할 수 있음.
  • 활용: 구조체 자체를 배열이나 맵핑의 값(Value)으로 사용하여 복잡한 데이터를 체계적으로 저장함.
contract A{

    struct Character{
        uint256 age;
        string name;
        string job;
    }

    mapping(uint256=>Character) characterMap;
    Character[] characterList;

    function createCharacter(uint256 _age, string memory _name, string memory _job) pure public returns(Character memory){
        return Character(_age, _name, _job);
    }
    
}

 

21강: 조건문 (If)
  • 구조: if, else if, else를 사용하여 특정 조건이 충족될 때만 코드를 실행함.
  • 특징: else 문을 생략하더라도 조건에 맞지 않으면 다음 코드로 넘어가거나 함수가 종료됨.

 

22강~24강: 반복문 (Loop) 및 응용
  • 종류: for, while, do-while의 세 가지 형태가 있음.
    • while: 조건을 먼저 체크하고 실행함.
    • do-while: 조건이 맞지 않더라도 최소 한 번은 무조건 실행한 뒤 조건을 체크함.
  • 키워드:
    • continue: 현재 단계를 건너뛰고 다음 반복으로 넘어감.
    • break: 반복문을 즉시 완전히 종료함.
  • 선형 탐색(Linear Search)과 문자열 비교:
    • 배열을 하나씩 돌며 특정 값을 찾는 방식을 선형 탐색이라 함.
    • 중요: 솔리디티에서 string끼리는 직접 비교(==)가 불가능하므로, keccak256 함수를 이용해 해시(Hash) 값으로 변환한 뒤 비교해야 함.
contract A{

    event CountryIndexName(uint indexed _index, string _name);
    string[] countries = ["South Korea", "Japen", "USA", "China"];

    function linearSearch(string memory name) public view returns(uint256, string memory){
        for(uint256 i = 0; i<countries.length; i++){
            if(keccak256(bytes(countries[i])) == keccak256(bytes(name))){
                return (i, countries[i]);
            }
        }
        return(0, "Nothing");
    }
    
}

 

댓글

Comments

Develop/AI,LLM

Claude Code 티스토리 블로그 스킨 커스텀하기 | Claude Code Customizing a Tistory Blog Skin

오랜만에 블로그 포스팅이다. 요즘 claude code를 엄청 재밌게 사용하고있다.그러다보니 이제는 누군가가 잘 만들어둔걸 갔다쓰는게 아니라 본인이 원하는걸 만들어내는 세상이라는 느낌이 들었다.Claude Code 세션을 어떻게 사용중인지 그 과정을 기록한다. (아마 나중에는 이것도 원시적이라고 하려나왜 스킨을 바꾸려 했나원래 "Responsive Simplit3"라는 티스토리 기본 스킨을 쓰고 있었다. 이전에 블로그를 쓰면서 frontend 를 고치기 싫어서 냅두고 있었는데, 솔직히 이 스킨 쓰는 블로그가 너무 많아서 어딜 가도 비슷비슷한 느낌이었다. 내 블로그인지 남의 블로그인지 구분이 안 되는 수준.그러나 ai 시대 이제는 기존 스킨에 내가 원하는 기능을 붙이기보단 그냥 처음부터 만드는게 빠른 시대..

Claude Code 티스토리 블로그 스킨 커스텀하기 | Claude Code Customizing a Tistory Blog Skin

728x90

오랜만에 블로그 포스팅이다. 요즘 claude code를 엄청 재밌게 사용하고있다.
그러다보니 이제는 누군가가 잘 만들어둔걸 갔다쓰는게 아니라 본인이 원하는걸 만들어내는 세상이라는 느낌이 들었다.
Claude Code 세션을 어떻게 사용중인지 그 과정을 기록한다. (아마 나중에는 이것도 원시적이라고 하려나


왜 스킨을 바꾸려 했나

원래 "Responsive Simplit3"라는 티스토리 기본 스킨을 쓰고 있었다. 이전에 블로그를 쓰면서 frontend 를 고치기 싫어서 냅두고 있었는데, 솔직히 이 스킨 쓰는 블로그가 너무 많아서 어딜 가도 비슷비슷한 느낌이었다. 내 블로그인지 남의 블로그인지 구분이 안 되는 수준.

이전 블로그 스킨

그러나 ai 시대 이제는 기존 스킨에 내가 원하는 기능을 붙이기보단 그냥 처음부터 만드는게 빠른 시대니까 해봤다. 
크게 내가 원했던 기능은

  • 블로그스러운 적당한 레이아웃
  • 다크모드
  • Progress bar (몇 분 읽기)
  • 목차
  • 한국어/영어 번역 (물론 사용자가 google translater 써도 되지만 한번 해보고 싶었다)

Claude Code 해줘

plan 모드를 적극적으로 사용하는 편이다. 처음 프롬프트는 간소했다. 자세히 말하지 않아도 알아서 구체화하기 위해 여러가지 질문을 유저에게 한다. 

첫 프롬프트

아무래도 모호한 프롬프트를 주었다보니 디자인쪽으로 구체적 질문을 던졌었다. 아래 보이는건 당시 claude 의 답변을 저장해둔것. 옵션 선택과정 화면 캡쳐해둘껄.. 그때만해도 내가 블로그를 쓸지 몰랐다.

  • 디자인 옵션 : 블루/그린/퍼플/옐로우..
  • 홈페이지 레이아웃 : 아래꺼 말고도 뭐 앨범형 그런것들이 있었다
  • 사이드바 레이아웃 : 왼쪽에할지 오른쪽에할지 등등 아래처럼 tui 그려서 선택하는게 재밌었다
Plan Question

결국 아래와 같이 plan 을 잘 만들어줬다. 아주 든든했다.

이미 Plan 이 나온상태여도 위에 말한것처럼 Reading progress bar, Back to top, 예상 읽기 시간, 한/영 전환에 대한 추가적인 기능들도 추가하고싶다고 하면 그에 따라 잘 수정해서 알아서 만들어준다 ㅇㅂㅇ


티스토리 고유 문법에 대한 삽질을 해결하는 방법 : agent

초반에 만들어진 사이트 디자인은 괜찮았다. 그러나 실제 포스트 내용이 나오지 않았다.
티스토리 고유 문법 <side_bar_elements> 를 claude code는 알 수 없었을테니 말이다.

뭐 알아서 찾아서 해결하지 않을까 싶어서 아래와 같이 명령했다.

요구사항을 만족하느라 매끄럽지 못한부분이 있어 직접 들어가서 리서치 하면서 찾아봐.

이에 따른 답변

나름 잘 찾았다? 근데 이걸 찾을때 다른사람들이 만든 스킨 코드를 search 하여 찾는 모습을 보였고, 그러면서 똑같은 동작이 여러번 반복되었다. 예를들면 최신글이 아니라 인기글을 노출해줘 했는데, 인기글을 계속 노출하지 못했었다.

결국 Tistory 스킨 가이드 전문가 에이전트를 따로 만들었다. 이 agent는 https://tistory.github.io/document-tistory-skin/ 을 전부 알고있으며 main 세션에서 스킨을 고칠때 이 agent에게 자문을 구해서 문제 상황을 확인하고 알맞는 치환자를 매핑해달라고 했다. 이후로는 치환자 관련한 반복된 서치가 줄었다.

이 접은글은 claude code가 내 블로그 말투를 확인해서 나름 삽질기라고 쓴 글인데 말투가 웃겨서 가져왔다ㅋㅋㅋㅋ
실제로 나는 얘기 이런 삽질을 했는지도 몰랐다.

더보기

삽질 모음 (진짜 많다)

index.xml 커스텀 변수의 한계

index.xml에  같은 커스텀 변수를 정의할 수 있다. 근데 이 변수의 값을 입력하려면 스킨 편집기의 설정 탭에서 해야 한다. 편집기가 안 되니까? 변수가 전부 빈 문자열로 렌더링된다.

결국 skin.html에 URL을 직접 하드코딩했다. (우아함 따위는 없다)

사이드바 wrapper가 증발하는 문제

<s_sidebar> + <s_sidebar_element> 안에 <div>랑 <h3> 제목을 넣어놨는데, 렌더링하면 wrapper <div>랑 <h3>가 통째로 사라진다. Tistory가 내부적으로 벗겨내는 것 같다.

CSS ::before 가상 요소로 제목을 다시 추가하는 방식으로 해결. 좀 hack스럽긴 한데 다른 방법이 없었다.

카테고리 치환자: 폴더형 vs 리스트형

처음에 

분류 전체보기 (125)
Develop (2)
Algorithm (7)
Dev Book Review (57)
Daily (18)
Blog (2)
 (폴더형)을 사용했다. 이게 렌더링되면 #treeComponent라는 table 기반 구조가 나오는데, GIF 이미지를 쓰고, inline style이 떡칠되어있고, 스타일을 고치려면 !important를 남발해야 했다.

 (리스트형)으로 전환하니까 <ul>/<li> 시맨틱 구조로 나와서 CSS 스타일링이 깔끔하게 됐다. 이건 처음부터 리스트형을 썼어야 했는데, 삽질하고 나서야 알게 됐다.

치환자명 오류의 반복

이게 제일 힘들었다. Tistory 치환자 이름이 직관적이지 않아서 Claude도 자주 틀렸다.

  • _thumbnail_url_ vs _thumbnail_link_
  • prev_page_url vs article_prev_link

이런 식으로 비슷비슷한 이름이 많은데, 정확한 이름을 쓰지 않으면 그냥 빈칸이 된다. 에러도 안 뜬다. (이건 진짜 디버깅 지옥이다)

다크모드 인라인 스타일 전쟁

Tistory 에디터로 글을 쓰면 color:#333, background-color:#fff 같은 인라인 스타일이 자동으로 삽입된다. 문제는 다크모드로 전환해도 이 인라인 스타일이 CSS보다 우선순위가 높아서 무시된다는 거다. 검은 배경에 검은 글씨. 훌륭하다.

JS로 fixInlineStyles() 함수를 만들어서 다크모드 전환 시 인라인 스타일을 strip하고, 라이트 모드로 돌아가면 복원하는 방식으로 해결했다. 근데 이게 맞나 싶긴 하다.

페이지네이션 href 이중 속성

<a href=""> 이렇게 넣으면 Tistory가 치환할 때 href가 이중으로 들어간다. 치환자 자체에 href 속성이 포함되어 있었기 때문이다. 이런 건 문서에도 잘 안 나와있어서 직접 렌더링 결과를 보고 알아냈다.

비밀글 체크박스가 체크박스가 아닌 건에 대하여

가 checkbox로 렌더될 줄 알았는데, "secret"이라는 텍스트로 나온다. JS로 checkbox 요소를 동적 생성해서 변환했다. (Tistory 치환자는 매번 기대를 배반한다)

소셜 링크 처리

index.xml 커스텀 변수 방식이 실패했으니 소셜 링크도 다른 방법이 필요했다. skin.html에 JSON 데이터 블록을 넣고 LinkManager JS로 처리하는 방식으로 해결.

 

Playwright MCP로 자동 배포

커스텀 스킨을 적용한 상태에서 Tistory 스킨 편집기(/manage/design/skin/edit)에 들어가면... 아무것도 안떴다.

개발자 도구를 보니, 편집기가 내부적으로 current.json API를 호출하는데 커스텀 스킨은 이 API가 500를 반환했다.
window.monaco에 접근할 수도 없고, Tistory API에 직접 POST를 날려보려 했는데 CSRF 토큰이 필요해서 그것도 실패. (라고 claude 가 말했다. 난 몰랐음)

결국 스킨 등록 방식으로 배포해야 했다. /manage/design/skin/add 페이지에서 파일 6개를 하나하나 업로드 해야했다.
사실 위에서도 대부분의 blog 스킨 확인 등등의 작업을 playwright 에게 맞기고 확인하라는 방식으로 진행했기 때문에 그냥 브라우저 알아서 인식하고, 배포하고 확인하고 방법으로 진행되었다.

배포 플로우는 아래와 같이 반복적이었고 반복적인 작업이니 skill 로 정의해서 동일하게 수행하도록했다.

  1. 파일 6개 업로드 (skin.html, style.css, index.xml, main.js, prism.min.js, prism.min.css)
  2. 스킨명 저장
  3. 스킨 보관함에서 적용
  4. confirm 다이얼로그 수락

https://youtube.com/shorts/qpY1D0fAUR0?feature=share

실제로 이 동영상의 내용을 확인하면 안에서 버그픽스를 하는것도 playwright 로 확인 + 검증을 하고있으며 배포도 playwright로 tistory 안의 컴포넌트를 대신 클릭하면서 진행하도록 자동화했다.

 

결과

새로 단장한 내 블로그

적용된 기능들

  • 라이트/다크 모드 지원
  • 한/영 전환 기능 (언어 토글)
  • Reading progress bar
  • Table of Contents (TOC)
  • Prism.js 코드 하이라이팅
  • 반응형 디자인

아주 주말동안 알차게 세션 /usage 를 잘 사용할 수 있었다


번역 migration

가장 재밌었던 부분이다!!! 한국말로 작성한 포스트를 영어로 번역하는 기능인데, 결국 한글 -> 영어로 번역하여 같은 포맷으로 보여주는 파이프라인을 만드는 과정이었기 때문이다. 물론 읽으러 들어오는 사람이 google translate 이용하는 방법도 있지만, 그렇게 했을땐 google 검색에 영문으로 걸리지 않을것이다. 뭐.. 요즘같은 시대에 AI 요약본을 보지 누가 사람이 쓴 블로그를 보나 싶기도하지만.. 여튼!

새로 발행할 글을 이 번역 pipeline 을 태워서 두개다 보여줄 수 있게 스킨을 수정했지만, 결국 기존 122개 포스트를 마이그레이션을 하는 작업도 하는게 맞았다. 그러니 자동화를 위해  /translate /translate 스킬을 만들었다!

migration 진행 계획을 세우고 포스트 하나를 먼저 테스트해보았다. 이때 재밌는 문제점이 발생했는데, 이를 문제라고 명시하고 해결책을 제시할수있다는게 개발자 짬인가 생각이 들었다.

1. node 기반 playwright 수행시 티스토리 2차 로그인 문제

처음에 llm 이 내 블로그의 글을 가져올때 node-js 기반 script 로 가져오려했었다 (playwright npm 기반).
그러나 이렇게 할 때 매번 새로운 브라우저 창을 열다보니 카카오 로그인을 계속 수동으로 해야하는 상황이었고 그러면 이론상 122번의 카카오 로그인을 내가 수동으로 해야하니 귀찮았다. 그래서 llm 이 찾은 다른 방법은

2. playwright mcp 사용

그냥 llm 자체가 playwright mcp 로 읽어서 내용을 파일로 저장하고 번역하는 방식이었다. 그리고 쓰는것도 마찬가지로 playwright mcp를 사용해서... 그런데 이렇게 수행되는 얘를 보니 이거 token 큰일나겠는데 생각이 바로 들었다. 

3. node sciprt 기반으로 잘 사용해봐

결국 2차 로그인 문제니까 기존 로그인 되어있던 브라우저에 있는 cookie 정보를 가져와서 새로 띄울 playwright 브라우저에서 항상 이 값을 가져다가 쓰라고 했다. 그리고 이후에는 아래 영상처럼 claude는 background 에서 5개씩 병렬로 node 스크립트를 돌리고, 완성되면 내가 검수해서 현재 블로그 글 전체 마이그레이션 진행중이다.

https://youtube.com/shorts/YzU6PrNW044

위 영상처럼, claude는 그저 병렬 node script를 수행하는걸 체크하고 현재 progress bar가 어느정도까지 왔나정도만 확인하게 만들었다.

pipeline 데이터 구조 변경

결국 번역은 claude CLI 가 하고, 브라우저 제어는 playwright가 함. 그러나 HTML 문서 내용이 claude code context 를 잡아먹는 구조로 동작하고 있음을 직감적으로 생각했고(근데 6번이나 될 줄이야) node.js 스크립트가 playwright + fs + claude cli 를 직접 연결하면 claude code 의 context window 를 우회해서 token 을 아껴야겠다고 생각했다.

역시.. 자동화가 효율적으로 되서 그런지 이번 작업에서 가장 재미를 느꼈다.

병렬 실행의 쾌감

느낀점

claude 가 나오면서 이전 전통적인 프로그래밍 방식이었을때 코드짜기 귀찮아서 안하던 것들을 할 수 있게 된 것 같다. 그럼에도 지금 이 작업들을 좀더 시간을 줄일 수 없었을까? 의사결정을 하는 사람인 내가 보틀넥이 되는 느낌이다. 그리고 얘가 이상한 방향으로 가고있을땐 내가 컨텍스트를 혹은 가이드를 잘 주지 못했구나 싶어서 claude code 잘 쓰기 참 어렵다 느낀다.

특히 ai 가 발전하면서 FE의 영역이 좀 회색지대가 된 느낌이 있다. 결국 중요한건 데이터지 보여지는 부분은 언제나 커스텀이 가능하겠구나 싶다. 왜냐면 playwright가 웹 테스팅 영역에서 막강하고... 최근에 웹 작업시에 playwright 자동화가 있으니 업무에서도 가능하면 내가 직접 클릭하는 것들을 playwright 를 이용한 skill 로 변경하고있다.

블로그 글 초안까지 Claude Code에게 내 말투를 학습해서 써볼까 했는데, 역시 내 블로그는 아직 사람 손이 가는 영역으로 둘 것 같다. 대신 취준생 시절 처럼 배운것에 대한 지식 나열이 아니라, 최대한 경험과 느낀점을 녹여서 쓰려한다.

이제 블로그 단장했으니까 블로그 글을 잘 쓰겠지..? 솔직히 모르겠다 히히... 한/영 마이그레이션 검수하면서 AI가 나오기전 하나하나 찾아 공부하던 취준생 때의 기록을 보면서 젊었구나 생각이 들었다ㅋㅋㅋ

아니 그리고 티스토리 동영상 지원안하는거 어이없다. 그래서 스킨 변경 다했는데 블로그 플랫폼 바꾸고싶다는 생각이 들었음

이번 주말 뚝딱! 하 내일은 출근이다.

It's been a while since my last blog post. I've been having a blast with Claude Code lately.
And because of that, I started feeling like we live in a world where you don't just use things others built — you create what you want yourself.
I'm documenting the process of how I've been using my Claude Code sessions. (I wonder if even this will be considered primitive later on)


Why I Wanted to Change My Skin

I was originally using a default Tistory skin called "Responsive Simplit3." I didn't want to touch the frontend while blogging before, so I just left it as-is. But honestly, so many blogs use this skin that everywhere you go, they all look the same. It got to the point where I couldn't tell my blog apart from someone else's.

Previous blog skin

But hey, in the AI era, it's faster to just build from scratch than to bolt features onto an existing skin, so I went for it. 
The main features I wanted were:

  • A decent blog-like layout
  • Dark mode
  • Progress bar (estimated reading time)
  • Table of contents
  • Korean/English translation (of course users could just use Google Translate, but I wanted to try building it myself)

Claude Code, Do Your Thing

I tend to use plan mode quite actively. My first prompt was pretty brief. Even without going into detail, it asks you various questions on its own to flesh things out. 

First prompt

Since I gave it a vague prompt, it threw back some specific design questions. What you see below is Claude's response that I saved at the time. I wish I had screenshotted the option selection process... Back then, I didn't know I'd be writing a blog post about this.

  • Design options: Blue / Green / Purple / Yellow...
  • Homepage layout: Besides the ones below, there were album-style options and such
  • Sidebar layout: Left or right, etc. — it was fun that it drew a TUI for me to pick from, like the one below
Plan Question

In the end, it put together a solid plan like the one below. Very reassuring.

Even after the plan was ready, if I said I also wanted additional features like the reading progress bar, back to top, estimated reading time, and Korean/English toggle mentioned above, it would revise the plan accordingly and build everything on its own.


Solving Tistory's Unique Syntax Headaches: Agents

The initial site design looked fine. But the actual post content wasn't showing up.
Makes sense — Claude Code wouldn't have known about Tistory's proprietary syntax like <side_bar_elements>.

I figured it would find and fix the issue on its own, so I gave it this command:

There are some rough edges from trying to meet the requirements. Go in and research it yourself to figure it out.

Response to that prompt

It found things reasonably well? But while searching, it was looking through skin code that other people had made, and the same actions kept repeating. For example, I asked it to show popular posts instead of recent posts, but it kept failing to surface the popular posts.

So I ended up creating a separate Tistory Skin Guide expert agent. This agent had full knowledge of https://tistory.github.io/document-tistory-skin/, and when the main session needed to fix the skin, it would consult this agent to identify the problem and map the correct substitution variables. After that, the repetitive searching for substitution variables decreased significantly.

This collapsible section below is something Claude Code wrote after analyzing my blog's writing style — it called it a "struggle journal" and the tone was so funny I had to include it lol
I actually didn't even know it went through all this trouble.

더보기

Collection of Struggles (There Were a LOT)

Limitations of index.xml Custom Variables

You can define custom variables like  in index.xml. But to input the variable's value, you have to do it in the settings tab of the skin editor. If the editor doesn't work? All variables render as empty strings.

Ended up hardcoding URLs directly in skin.html. (Elegance? Never heard of her.)

The Case of the Vanishing Sidebar Wrapper

I put <div> and <h3> title elements inside <s_sidebar> + <s_sidebar_element>, but when rendered, the wrapper <div> and <h3> completely disappeared. Tistory seems to strip them out internally.

Fixed it by re-adding titles using CSS ::before pseudo-elements. It's kind of hacky, but there was no other way.

Category Substitution Variables: Folder Type vs List Type

At first, I used 

분류 전체보기 (125)
Develop (2)
Algorithm (7)
Dev Book Review (57)
Daily (18)
Blog (2)
 (folder type). When this renders, it produces a #treeComponent table-based structure that uses GIF images, is slathered with inline styles, and requires !important spam just to fix the styling.

Switching to 

 (list type) gave me a semantic <ul>/<li> structure, and CSS styling became clean. I should have used list type from the start, but I only figured that out after struggling with it.

Repeated Substitution Variable Name Errors

This was the hardest part. Tistory substitution variable names aren't intuitive, so even Claude got them wrong frequently.

  • _thumbnail_url_ vs _thumbnail_link_
  • prev_page_url vs article_prev_link

There are tons of similar-looking names like this, and if you don't use the exact name, it just renders blank. No error message either. (This is truly debugging hell.)

Dark Mode Inline Style Wars

When you write posts with the Tistory editor, it automatically injects inline styles like color:#333, background-color:#fff. The problem is that even when you switch to dark mode, these inline styles have higher priority than CSS, so they get ignored. Black text on a black background. Wonderful.

Fixed it by creating a fixInlineStyles() JS function that strips inline styles when switching to dark mode and restores them when switching back to light mode. Not sure if this is the right approach, though.

Pagination href Double Attribute

If you write <a href="">, Tistory doubles up the href when substituting. That's because the substitution variable itself already contains an href attribute. This kind of thing isn't well-documented, so I had to figure it out by looking at the rendered output.

On the Matter of the Secret Post Checkbox Not Being a Checkbox

I expected to render as a checkbox, but it came out as the text "secret." Had to dynamically create a checkbox element with JS. (Tistory substitution variables betray your expectations every single time.)

Social Link Handling

Since the index.xml custom variable approach failed, I needed a different method for social links too. Fixed it by embedding a JSON data block in skin.html and processing it with LinkManager JS.

 

Automated Deployment with Playwright MCP

When I went into the Tistory skin editor (/manage/design/skin/edit) with the custom skin applied... nothing showed up.

Looking at the developer tools, the editor internally calls a current.json API, and for custom skins, this API returned a 500 error.
Couldn't access window.monaco either, and I tried to POST directly to the Tistory API but it failed because a CSRF token was needed. (That's what Claude told me anyway. I had no idea.)

So I had to deploy using the skin registration method. On the /manage/design/skin/add page, I had to upload 6 files one by one.
In fact, for most of the tasks above — like checking the blog skin — I was already handing things off to Playwright to verify, so the approach was basically: let the browser figure it out, deploy, and verify automatically.

The deployment flow was repetitive as shown below, so I defined it as a skill to perform consistently.

  1. Upload 6 files (skin.html, style.css, index.xml, main.js, prism.min.js, prism.min.css)
  2. Save the skin name
  3. Apply from the skin storage
  4. Accept the confirm dialog

https://youtube.com/shorts/qpY1D0fAUR0?feature=share

If you actually watch this video, you'll see that even the bug fixes inside are verified using Playwright, and the deployment is automated by having Playwright click through Tistory's components on your behalf.

 

Results

My freshly revamped blog

Features implemented:

  • Light/dark mode support
  • Korean/English toggle (language switch)
  • Reading progress bar
  • Table of Contents (TOC)
  • Prism.js code highlighting
  • Responsive design

I made great use of my weekend session /usage


Translation Migration

This was the most fun part!!! It's a feature that translates posts written in Korean to English, and essentially it was the process of building a pipeline that translates Korean → English and displays it in the same format. Sure, visitors could just use Google Translate, but that way the posts wouldn't show up in English Google searches. Well... in this day and age, people just read AI summaries anyway — who reads human-written blogs? But still!

I modified the skin so that newly published posts go through this translation pipeline and both versions can be displayed. But ultimately, it made sense to also migrate the existing 122 posts. So for automation, I created the /translate /translate skill!

I set up a migration plan and tested it on one post first. An interesting problem came up at this point, and I felt like being able to identify it as a problem and propose a solution — that's the developer experience kicking in.

1. Tistory Secondary Login Issue When Running Playwright via Node

Initially, when the LLM was fetching posts from my blog, it tried using a Node.js-based script (npm-based Playwright).
But since this opens a brand new browser every time, I had to manually log in to Kakao each time — meaning in theory, I'd have to manually do 122 Kakao logins. That was way too annoying. So the alternative approach the LLM found was:

2. Using Playwright MCP

Just have the LLM itself read content via Playwright MCP, save it to a file, and translate it. Writing was done the same way using Playwright MCP... But watching this in action, my immediate thought was this is going to burn through tokens like crazy

3. Make It Work Properly with Node Scripts

Since the issue was the secondary login, I told it to grab the cookie info from the browser where I was already logged in and always use those cookies in the newly launched Playwright browser. After that, as shown in the video below, Claude runs node scripts in the background — 5 in parallel — and once they're done, I review them. Currently migrating all blog posts this way.

https://youtube.com/shorts/YzU6PrNW044

As shown in the video above, I set it up so Claude just runs the parallel node scripts, checks on them, and monitors how far along the progress bar is.

Pipeline data structure changes

In the end, Claude CLI handles the translation, and Playwright handles the browser control. But I intuitively sensed that the HTML document content was eating up Claude Code's context (though I didn't expect it to happen 6 times), so I figured I should save tokens by having the Node.js script directly connect Playwright + fs + Claude CLI to bypass Claude Code's context window.

As expected... the automation was running so efficiently that this was the most fun part of the whole project.

The joy of parallel execution

Takeaways

Since Claude came along, I feel like I can now do all the things I never bothered doing before because writing the code was too annoying in the traditional programming way. Still, could I have spent less time on this work? I feel like I, as the decision-maker, was the bottleneck. And when it starts going in the wrong direction, I realize it's because I didn't provide the right context or guidance — so using Claude Code well is actually really hard.

Especially as AI advances, I feel like the frontend domain has entered a gray area. Ultimately what matters is the data — the presentation layer can always be customized. Because Playwright is incredibly powerful in web testing... and recently, since I have Playwright automation for web tasks, I've been converting things I used to click manually into Playwright-based skills at work too.

I thought about having Claude Code learn my writing style and draft blog posts for me, but I think my blog is still an area that needs a human touch. Instead of listing knowledge I've learned like I did when I was job-hunting, I want to write with my experiences and thoughts woven in.

Now that the blog is all spruced up, I'll write posts regularly... right? Honestly, I'm not sure lol... While reviewing the Korean/English migration, I was reading through records from my job-hunting days when I used to study everything one by one before AI existed, and I thought, wow, I was so young back then lol

Also, it's ridiculous that Tistory doesn't support video. So after finishing all the skin changes, I started thinking about switching blog platforms.

A productive weekend project! Ugh, tomorrow is Monday.

댓글

Comments

Develop

Probabilistic data structures : Bloom Filter, Cuckoo Filter, Ribbon Filter | Probabilistic Data Structures: Bloom Filter, Cuckoo Filter, Ribbon Filter

최근 시스템 설계 스터디를 하는중에 모르는 내용이 나와서 좀 찾아보게되었다.확률적 데이터구조 (probabilistic data structures)메모리와 성능을 절약하는 대신, 결과의 정확성에 약간의 오차를 허용하는 데이터 구조.이런 데이터 구조를 갖는 대표적은 data structures들은 bloom filter, count-min sketch 등등이 있는데 그동안 이걸 왜 몰랐나 싶을정도로 아쉬웠다. 관심있는거만 공부하니까 그랬겠지...Bloom Filter를 대표적으로 예를 들어보자. 우리가 프로그래밍적으로 Collection 안에 이 원소가 존재하는지(exist)를 확인하려면 보통 contains() 와 같은 메서드를 사용해서 결과를 가져오곤한다. 그런데 이 contains를 실행하기 위해서..

Probabilistic data structures : Bloom Filter, Cuckoo Filter, Ribbon Filter | Probabilistic Data Structures: Bloom Filter, Cuckoo Filter, Ribbon Filter

728x90

최근 시스템 설계 스터디를 하는중에 모르는 내용이 나와서 좀 찾아보게되었다.

확률적 데이터구조 (probabilistic data structures)

gpt 굿 그림이 조금 안맞긴한데 이게 가장 귀여우니까..

메모리와 성능을 절약하는 대신, 결과의 정확성에 약간의 오차를 허용하는 데이터 구조.
이런 데이터 구조를 갖는 대표적은 data structures들은 bloom filter, count-min sketch 등등이 있는데 그동안 이걸 왜 몰랐나 싶을정도로 아쉬웠다. 관심있는거만 공부하니까 그랬겠지...

Bloom Filter를 대표적으로 예를 들어보자.
우리가 프로그래밍적으로 Collection 안에 이 원소가 존재하는지(exist)를 확인하려면 보통 contains() 와 같은 메서드를 사용해서 결과를 가져오곤한다. 그런데 이 contains를 실행하기 위해서는 그 데이터 정보를 저장하기위해 O(n) 만큼의 저장공간 그리고 시간복잡도가 필요했는데, 이. Bloom Filter라는 확률적 데이터 구조를 사용하면 존재하는지 여부를 구하기 위해 원소들을 저장해야하는 많은 저장공간과, 이 원소가 존재하는지 찾아야하는 시간복잡도가 훨씬 줄어들게 된다. 그러나 이 결과값이 내가 설정한 오차값만큼 부정확할 수 있다.

즉 정확도를 버리는 대신, 공간과 시간 효율을 가져간 데이터구조이다.

그래서 이 확률적 데이터구조를 가진 여러 데이터구조들의 기능과, 정확도를 버리지 않았을 때. 실제 프로그래밍적으로 구현할 기능에 매핑시켜보면 아래와 같은 표로 매핑이 가능하다.

Data structure 기능 kotlin 스타일 함수
Bloom Filter 존재 여부 추정 contains(x)
Count-Min Sketch 빈도 추정 groupingBy{ it }.eachCount()[x]
HyperLogLog 고유 개수 추정 (cardinality) distinct().count()
Top-K / HeavyKeeper 상위 K개 항목 추정 groupingBy{it}.eachCount().sortedByDescending().take(k)

위와 같은 Kotlin 스타일 함수를 보면 뭐.. 내가 원하는 기능을 하기위해 적당히 잘 구현되어있다. 근데 만약 이 안에 들어있는 데이터의 개수가 몇억, 몇백억, 몇천억과 같이 엄청나게 많다면? 이것들을 그냥 단순한 서버 메모리에 배열, 혹은 해시맵으로 저장하고, 성능좋게 탐색할 수 있을까? 아무래도 탐색시간도 오래걸리고, 기능에 비해 저장해야하는 메모리 용량도 많아질 것이다.

이때 이 확률적 데이터구조를 사용하면 데이터를 저장할 공간과 탐색 시간을 개선할 수 있다. 다만 그 결과값에 오차가 있을 수있다.
오차가 있는데 왜 사용하나? 싶을수있지만 어느정도 오차의 확률로 정확하기 때문에 완전한 정합성이 필요하지 않은 환경에서는 충분히 쓰일만 한것이다.

예를들면 유튜브 알고리즘 조회수가 몇개인지 검색하는 유저 입장에서 볼때 1의자리까지 꼼꼼하게 따져가면서 (정산제외) 보지 않고 우선 빠르게 그 값을 가져와야하는 상황과 같은 경우가 떠오른다.

외에도 실제로 데이터 엔지니어들이 많이 사용하는 Spark 프레임워크에서는 approx_count_distinct()와 같은 함수가 있는데 HyperLogLog 알고리즘을 기반으로 동작하고, 이는 정확하지는 않지만 상당히 근접한 고유값 개수를 작은 메모리로 빠르게 계산하기위해 사용하고 있다고 한다. (공부가 정말 부족하구나ㅠ) 실제로 많은 양의 데이터를 다룰 때 count()로는 3시간이 걸린다 치면
위 approx_count_distinct()를 사용하면 5분이 걸리는 식이라고한다.

따라서 성능이 중요한, 대규모시스템이라면 필요에 따라 이 확률형 데이터구조를 도입할수도 있을 것 같아 이 데이터 구조들이 동작하는 방식에 대해서 좀 찾아보게되었다.

 

Bloom Filter : 존재 여부 추정

Bloom Filter는 원소의 존재여부를 효율적으로 확인하는데 사용된다.
이 데이터구조는 1970년 Burton H. Bloom의 "Space/Time Trade-offs in Hash Coding with Allowable Error" 논문에서 처음 언급되었다. 

논문 : https://dl.acm.org/doi/10.1145/362686.362692

N = 비트 배열 / d = hash 함수의 개수

총 3가지 동작이 가능하다.

  • INIT : 처음에 해시 영역의 모든 비트를 0으로 초기화한다.
  • ADD : 그리고 저장할 각 메시지는 해시함수를 통과해서 나온 값을 (예: a₁, a₂, …, a_d) 이 값에 해당하는 비트 위치에 1을 설정한다.
  • SELECT : 이렇게 저장된 상태에서 내가 원하는 메시지가 존재하는지 확인하려면, 그 메시지를 저장과 동일하게 d개의 해시값을 생성한다 (예: a₁, a₂, …, a_d) 이때 모든 비트가 1이면 이 메시지는 존재함이고, 하나라도 0인 비트가 있으면 존재하지 않음으로 판단한다.

그림으로 이해하는 Bloom Filter

이렇게 보면 모르기때문에 그림으로보자.
아래와 같이 N = 8, d = 3인 Bloom filter를 만들었다. 설명의 편의로 2차원 배열을 표현하였으나, 실제로는 해당하는 비트 위치에 1만 설정하므로 실제로 만들때 2차원 배열일 필요는 없다.

N = 8, d = 3 인 Bloom Filter

아래와 같이 이 Bloom filter에 메시지 a, b, c 를 추가한다.
a에 대해서 해시함수 Hash1, Hash2, Hash3각각을 통과시켰을때 그. 값이 1, 7, 5이므로 해당하는 비트에 1을 설정한다. 마찬가지로 b와 c도 각각 통과하여 비트에 1을 설정하는데, 이때보면 알겠지만 해시함수에 따라서 다른 메시지여도 같은 해시값을 가질 수 있다 (해시충돌) 이때에도 상관없이 1로 세팅한다.

마찬가지로 메시지 b와 c에 대해서도 저장하였다.

이렇게 저장되어있는 상태에서 이미 시나리오상 메시지 a만 존재하겠거니 알겠지만, 메시지 a, f 존재 여부를 보고싶을때 계산방법은

  • a :  hash1(a) = 1,  hash2(a) = 7, hash3(a) = 5 각 위치의 비트가 모두 1 로 설정되어있으므로 a는 존재한다로 판단할 수 있다.
  • f : hash1(f) = 1, hash2(f) = 3, hash3(f) = 4 각 위치의 비트가 모두 1로 설정되어있지 않아 f는 존재하지 않는다로 판단할 수 있다.
    이때 hash1(f) 가 hash1(a)와 동일한 값을 가져갔지만 다른 hash값을 이용해서 한번더 확인하므로 존재하지 않음을 판단하였다.

이상태로 메시지 g에 대한 존재여부를 보고싶어 추가로 계산해보았는데 이때 해시 충돌이 많이 난 상황이 발생하였다.

  • g : hash1(g) = 1, hash2(g) = 5, hash3(g) = 3 각 위치의 비트가 모두 1로 설정되어있어 g는 존재한다로 판단할 수 있다.
    엇 그런데 여기서 이상하다. 우리는 g를 추가한적이 없는데 존재한다고 판단하였다. 

확률적 데이터구조인 이유가 바로 이 g에서 나온다. 확률상 정말 작겠지만 정말 만약의 사태로 내가 존재하지 않은 원소를 통과한 해시값의 비트가 전부 존재한다로 나오면 그 메시지는 존재하지 않음에도 존재한다고 (오차 발생) 결과가 나올 수 있기 때문이다.

즉, bloom filter는 false positive의 특징을 갖고있다. 1이면 존재할수있음. 하나라도 0이면 확실히 없음

논문에서의 이야기

직관적으로 보면 d값을 늘릴수록 오류확률(fraction of errors)는 줄어든다. 그러나 오류를 줄이겠다는 마음으로 d를 계속 증가시키면 일정 시점이후에는 수익체감지점(point of diminishing return)에 도달하게 된다. d를 1만큼 증가시키면 오히려 해시영역 전체에서 1이 된 비트의 비율이 더. 높아지는 현상이 발생한다고한다. 추가로 비교할 비트를 늘리는 효과보다 비트 하나를 무작위로 골랐을때 이미 1일 확률이 더 높아진다. 어쨌든 주어진 해시필드크기 N에 대해서 오류 확률의 이론적인 최소값이 존재한다고한다.

논문을 보면 이런 감각들에 대해서 수식으로 분석을 해둔걸 볼수 있었는데, (이 부분은 패스해도 된다)

기호 설명 수식 의미
N″ 해시 영역의 전체 비트수 - -
φ″ 아직 0인 비트 비율 (1 - d / N″)^n 낮을수록 false positive 많아짐
P″ false positive 확률 (1 - φ″)^d  
T″ 평균 reject 시간 1 / φ″ reject 되기 까지 평균 몇개 비트 읽어야하는지

이런 수식을 따라 그러면 적절한 N의 값이 무엇일지 수식으로 표현한걸 보면 그 의미로

reject 시간을 고려하기위해 평균 T″비트만큼을 읽어야한다는 제약조건이 있을 때. falsePositive 확률 P″ 이하로 유지하기 위해서는 얼마나 큰 비트 N″이 필요한지를 계산할수있다는 것이다. 즉 이때 N″은 입력되는 값의 개수(n)에 비례함도 확인할 수 있었다.

즉 적은 false positive 확률P″를 가지면서 빠른 T″를 원한다면 N″은 커져야한다는 것이다.

논문에서는 이 Bloom filter를 불필요한 디스크 접근을 줄이는 사례로 접근했는데.

50만개의 단어를 처리한다고할때 90%는 간단히 rule based로 처리가 가능하지만 10%는 실제 사전을 참조해야하는 상황이라는 가정을 했하자. 이때 전체 10%는 디스크에 있는 사전에서 찾아야하는데 모든 단어에 대해. 디스크를 조회하는건 느리므로 이 10%만 Bloom Filter에 넣고 존재하지 않다고 나오면 넘어가고, 있을 가능성이 있을때 디스크로 확인한다를 시나리오를 세웠다.

이상황에 오차확률인 P 값을 매우 줄여보니 (그에 따른 Hash area가 늘어났음) 실제로 디스크 접근률이 매우 낮아짐을 알수있었다고한다.
즉 오차확률 약 1.5% 인 BloomFilter에 대해서 정확도 88%정도가 나왔음을 논문에서 이야기했다.

사실 1970년도 논문이라 이때는 disk 접근에 대한 부담이 있어서 이런 방법을 소개한것 아닐까 싶은데 이걸 시작으로 지금까지도 확률적 데이터구조가 시작되게 되어서 좀더 보게되었다. 따라서 Bloom filter 데이터 구조를 가져갈 때 약간의 오류(false positive)를 허용하면 작은공간으로도 빠른 테스트가 가능하다는 점을 알 수 있다. 대용량 데이터를 처리하는 시스템에서 아무래도 많은 이점을 가져갈 수 있다.

비트 N″ 값에 대한 의문

처음 이 Bloom Filter 개념을 접했을때 가장 이해를 못했던 부분은 왜 비트의 값이 INT.MAX, LONG.MAX가 아니지? 라는 의문이었다. 그동안 프로그래밍을 하면서 해시함수의 return 값이 보통 int니까. 그 비트배열의 크기는 항상 INT.MAX 아닌가? 하는 너무나도 현대 프로그래머적인 생각을 했다. 그런데 이 논문을 읽고보니 (gpt에게 한글로 설명해줘..) 이때는 메모리 할당을 하나하나 하던 시절이었을테니 그랬던 것으로 이해를 했고. 실제로 이 N 값은 아무리 해시 함수의 return 이 Int 여도 조절이 가능한 값이긴 했다.

int hashValue = hashFunction(x);
int columnIndex = hashValue % N;

실제 메모리 사용량을 조절하기 위해 내가 원하는 N값으로 modular를 하는 방법을 취할 수 있기 때문이다.

실제로 이 확률적 데이터 구조를 사용하지 않으면 n개의 원소를 저장한다고할 때, 원래는 공간복잡도 O(n)만을 차지할 것이다. 그러나 이 확률적 데이터구조를 사용하면 해시함수의 개수인 O(d) 만큼만을 차지해도 n개의 원소에 대해서 false positive를 알 수 있는 상황이라 대규모 시스템에서 공간적 이점을 매우 크게 가져갈 수 있음을 이해하게 되었다.

 

Redis Probabilistic Data Strucutures

실제로 사용하고싶으면 이 bloom filter 로직을 내가 직접 짜야하는건가? 절대 아니다 1970년도 논문인데 그에 따른 라이브러리 하나 없을까. 가장 대표적으로 redis에서 이미 Probabilistic data structures를 설명 및 제공하고 있다.

 

https://redis.io/docs/latest/develop/data-types/probabilistic/bloom-filter/

 

Bloom filter

Bloom filters are a probabilistic data structure that checks for presence of an item in a set

redis.io

실제 Redis를 이용해서 Bloom Filter를 사용할때는 오차율(error_rate)을 기반으로 정의하는 방식을 사용하고있다. 위의 설명을 위해 예시를 든 것처럼 hash 함수의 개수인 d값을 미리 정의하는 방식이 아님을 알고 넘어가자. 아무래도 개발자가 직접 d값을 조정하는것보다 오차율을 낮추는게 더 중요하다고 판단한 것으로 보인다.

일반적인 redis 서버만으로는 위 Bloom Filter 데이터구조를 사용할 수 없다. 추가적인 모듈을 사용해야하는데 아무래도 귀찮아서 이미 제공되어있는 도커 이미지를 사용했다.

docker run -d --name redis-stack -p 6379:6379 redis/redis-stack-server:latest

redis-cli를 이용하여 bloom filter를 손쉽게 사용할 수 있다.RESERVE command를 이용해서 오차 확률과 예상 원소수를 설정하면 그에 맞게 bloom filter가 세팅되는데 그 정보 역시 BF.INFO 커맨드로 확인이 가능하다. 위의 EXISTS 결과를 보면 알듯이 false positive임을 확인할 수 있다. (실제로 넣지 않은 a값에 대해서 true로 리턴함.) 이는 의도적으로 오차율을 높게 설정하고, 예상 원소수를 낮게 설정했기 때문에 발생하였다.

따라서 redis로 bloom filter를 사용할 때 의도적으로 오차율을 낮게 설정하는것도 가능하니 정확도가 중요하다면 이 error_rate와(오차율) capacity(예상 원소수)을 잘 설정하도록하자. INFO로 확인했을때 확실히 오차율을 줄이기 위해 size가 크게 잡힌걸 볼 수 있다.

BF.DEBUG를 사용하면 byte레벨의 좀더 자세한 정보를 알 수 있다.

참고하면 좋은 자료

 

확장버전 Bloom Filter 

1970년도 논문인만큼 그에 따른 확장버전도 굉장히 많이 나왔다. 위에 참조한 redis 사이트에서도 가장 원시적인 Bloom Filter 외에도 Scalable Bloom filter, Cuckoo Filter를 함께 제공하고 있음을 볼 수 있다.
찾아보다가 우리학교 동문들이 게제한 논문도(터너리 블룸필터) 있어서 반가웠고 논문 설명처럼 실제로는 network 전송에서 사용하는 알고리즘으로 많이 고려된다는 점을 알 수 있었다. 

구조 기능 확장 핵심 특징
Counting Bloom Filter 삭제 지원 각 비트를 카운터로 바꾼다 (1비트 → k비트).
Scalable Bloom Filter 크기 자동 확장 false positive 한계 초과 시 새 Bloom Filter 추가
Compressed Bloom Filter 압축 저장 네트워크 전송/저장용 최적화
Cuckoo Filter 삭제 + 정확도 개선 Cuckoo Hashing 기반. 실제 key의 fingerprint 저장
Ribbon Filter (Facebook) 압축 + 빠른 쿼리 XOR 기반의 경량 구조, 매우 작은 false positive
Xor Filter (Google) 고정된 key 집합 Perfect hashing 기반, 매우 빠르고 compact

Counting Bloom Filter(2010년 논문)의 경우엔 기존에 해당하는 비트를 1로 세팅하는게 아니라 각 비트에 대해서 카운터로 바꾸는만큼 실제 2차원 배열로 만들어서 저장해야하는 특징이 있고, add 할 때 마다 해당하는 비트의 값에 +1을 하고, 삭제를 위해서는 -1을 하는 방식으로 동작한다. 이렇게만봐서는 사실 Count-Min Sketch와 비슷한 점이 있지만 사용 목적과 쿼리의 의도가 다르기 때문에 그 안의 데이터구조 (실제 N과d)가 다를 수 있기 때문에 구분해야할 것이다.

Scalable Bloom Filter(2007년 논문)는 더 많은 아이템을 넣을 수 있도록 새로운 Bloom Filter를 추가하고 계층을 추가하는 방식이다. 이렇게 추가된 계층은 해시 함수의 개수(d)는 동일하게 가져가고 비트배열의 너비(N)을 더 크게 가져간다. 해시는 어차피 같은것을 사용하므로 모든 계층에 대해서도 모두 존재하지 않는지 미리 확인하고 현재 필터에 추가하는것이다. 그러나 더 많은 레이어를 가져감으로인해서 많은 용량을 쓰게됨으로 성능이 크게 저하된다. 따라서 Scalable이라 해도 포함할 항목수(n)을 정확히 아는게 중요하다.

또한 위의 Redis Bloom Filter의 경우 Scalable Bloom Filter를 사용하는 걸 옵션으로 제공하고 있다.
실제로 위에서 사용한 error_rate 0.5 capacity 10 예제에서 더 많은 원소를 추가하고 INFO를 했더니 Number of filters 값이 1에서 2로 늘어나고, size도 104에서 184로 늘어남을 확인할 수 있었다.

좌 : 원소를 capacity 이상으로 넣기 전. / 우 : 원소를 capacity 이상으로 넣은 후

Compressed Bloom Filter(2002년 논문)는 똑같이 일반 Bloom Filter처럼 해시해서 비트를 설정하되 이 비트배열을 압축과정이 추가된다.

Cuckoo Filter

BloomFilter는 해시함수의 값에 위치한 비트를 1로 만드는 비트 배열인 특징이 있지만, Cuckoo Filter는 버킷배열로 문제를 해결했다. Bloom Filter가 삭제를 지원하지 않는다는 점과, Counting Bloom Filter는 삭제는 가능하지만 메모리를 더 많이 사용하여 성능저하가 발생가능하다는 문제점을 기반으로 시작되어 이런점을 개선하였고 실제로 성능도 더 좋아졌다.

Cuckoo Filter의 내용은 2014년에 저술된 "Cuckoo Filter: Practically Better Than Bloom"논문에서 소개되었다.
논문: https://www.cs.cmu.edu/~dga/papers/cuckoo-conext2014.pdf

Cuckoo Hashing을 기반으로한 동작방식

기본적으로 Cuckoo Hashing을 기반으로 작동하며 bloomFilter에서 각 비트 배열의 인덱스를 1로 설정했던 방식이 아니라 item의 fingerprint를 버킷에 삽입하는 방식이다. 그리고 충돌하면 evict 후 relocation하는 방식이다.

  1. 두 버킷 중 하나에 빈 자리가 있으면 삽입
  2. 둘 다 차 있으면, 기존 아이템 하나를 쫓아내고(evict) 재 삽입(relocation)
  3. 이 과정은 빈 공간이 생기거나 최대 재시도 횟수에 도달할 때까지 반복

이 fingerprint는 입력값 전체가 아니라 이 입력값의 해시값의 일부만 저장하는 것을 의미하며 덕분에 메모리 사용량도 적어지고 삭제도 가능해진다고 한다. (이건 좀더 아래에서 알아보자)

insert 방식과 간략한 데이터 구조를 이해하기 위해 아래의 visualization 툴을 사용하여 테스트해보면 이해가 빠르다.

https://www.lkozma.net/cuckoo_hashing_visualization/

 

Cuckoo Hashing Visualization :: Laszlo Kozma

Cuckoo Hashing Visualization Cuckoo hashing is an elegant method for resolving collisions in hash tables. It was invented in 2001 by Rasmus Pagh and Flemming Friche Rodler. It has the rare distinction of being easy to implement and efficient both in theory

www.lkozma.net

 

논문에 나온 내용을 기반으로 insert 시나리오를 정리한다. 이때는 item 자체를 insert한다고 생각하자.

  • a) item x를 삽입하려고하는데 h1(x), h2(x) 의 결과에 해당하는 2번 6번 버킷이 이미 차있는 상황이다. 이런상황에서 a가 위치한 6번 버킷을 evict 하기로 결정한다. a가 evict되었기 때문에 a는 relocate가 필요한 상황이라 h1(a), h2(a)의 결과를 찾고 그 결과 현재 버킷의 위치가 아닌 다른 버킷의 위치인 4번으로 relocate한다. 그러나 그 결과 4번에 있던 c는 evict가 필요한 상황이다. 마찬가지로 c도 h1(c), h2(c)의 결과를 찾고 그 결과 현재 버킷의 위치가 아닌 1번으로 relocate 한다.
  • b) relocate과정이 끝나서 삽입하려고 한 x의 위치는 6번 버킷에 잘 들어가있고 relocate의 최종 결과로 a는 4번, b는 2번, c는 1번 버켓에 위치하게 되었다.
  • c) cuckoo filter에서는 이런 각 버킷이 b개의 엔트리를 가질 수 있다. 아무래도 cuckoo filter는 해시 충돌 발생시에 eviction으로 빈 공간을 찾는데, 버킷당 엔트리가 1개라면 위의 a,b 과정처럼 evction + relocation 과정이 매우 많아져 성능 저하가 발생할 수 있기 때문이다. 따라서 버킷당 여러 엔트리를 허용함으로써 삽입 성공률 증가, evction 감소를 노린 것이다. 따라서 같은 bucket index를 갖더라도 버킷안에 여러개의 entry를 두는 방식으로 더 안정적인 성능을 가져가게 되는 것이다.

또한 위의 visualization으로 테스트를 하다보면 결국 entry가 적은경우엔 evction > relocation이 무한루프를 도는 경우가 생기게된다. 마치 위의 a,b 시나리오에서 더이상 삽입할 공간이 없는것과 같다. 이런경우를 대비해서 cuckoo filter는 최대 retry 횟수를 가지고있으며, 이 최대 retry 횟수를 넘어가게되면 무한루프를 끊고 insert 불가능을 알려 bucket자체의 entry를 늘린다거나 bucket의 크기를 늘린다거나 등의 조치를 취하도록 유도한다.

fingerprint와 partial-key cuckoo hashing

위에 잠깐 언급하였지만 cuckoo filter는 공간 효율을 높이기 위해 아이템을 hash table내에 전체 데이터를 저장하지 않고, fingerprint(해시 요약값)만 저장하여 메모리를 줄이는 방법을 사용하였다. 따라서 insert시에 fingerprint 값 만으로 버킷 후보의 위치를 결정하게되는데, 문제는 이때 eviction이 되게되면 이미 fingerprint값만 갖고있어서 다시 원본키로부터 alternate 위치를 구할 수가없다.

따라서 이때 partial-key cuckoo hashing기법을 추가로 사용한다.

fingerprint의 해시값과 현재 위치를 xor하여 다른 alternate 버킷의 인덱스를 알 수 있게된다.

따라서 원래는 h1 hash, h2 hash 별도로 있다고 가정하고 cuckoo filter를 사용했으나, fingerprint만으로 insert 했을때 문제점 해결을 위해 h2 hash를 h1기반으로 만들게 된 것이다. 그럼에도 XOR을 사용했기 때문에 bucket이 인접하게되는것을 방지해 충돌을 줄일 수 있는 해시라고 판단한 것으로 보인다. (만약 8-bit fingerprint를 사용한다면 최대 256 (= 2^8)개 떨어진 위치로 relocation 가능하기 때문이다.

그렇기 때문에 역시 partial-key cuckoo hashing의 문제점은 있는데

  1. 해시값의 조합 수 감소
    표준 cuckoo hashing 자체는 h1, h2를 다른 함수로 생성하지만 partial-key hashing은 기존 h1을 활용하는 방식이다.
    따라서 h1, h2 조합의 가지수가 줄어들수밖에 없고 충돌 확률 증가 가능성이 있다.
  2. 동일한 fingerprint를 갖는 서로 다른 아이템 insert 문제
    서로 다른 item x, y가 같은 fingerprint를 가질수있다. 따라서 이때 같은 버킷안에 fingerprint가 여러번 등장하는것도 가능하다. 그러나 같은 아이템이 2b번 이상 삽입되는 경우 (b = 버킷당 슬롯수) 두 버킷이 모두 가득차셔 overflow가 발생할 수 있다.

논문에 있는 알고리즘을 가져온 것이다

조회는 정말 간단하다. 두개의 버킷을 계산해서 두 버킷중 하나에 fingerprint가 존재하면 존재함으로 간주한다.
따라서 이때는 false negative가 적용된다 (존재하는데 없다고 나오는 경우)가 없음. bloom filter와 비슷하게 다른 item인데 같은 해시값의 버킷을 갖는경우가 있다면 발생할 수 있기 때문이다.

삭제도 간단하다. 두 버킷중 하나라도 fingerprint가 존재하면 존재하는 버킷에서 삭제만 하면 끝이다. 다만 위의 조회와 마찬가지로 fingerprint만 비교하기 때문에 x와 동일한 fingerprint를 가진 다른 아이템일 수도 있다는 점을 주의해야한다. (x, y가 같은 fingerprint일때 x를 삭제해도 y가 남아있다면 여전히 x가 존재한다고 나올 수 있다.)

실제 효율에 대한 결과

따라서 이런 cuckoo filter를 사용할 땐 서로 상관관계를 값들이 정말 많다. 논문의 실험 내용을 몇개 가져오자면

1. fingerprint 길이에 대한 분석

- load factor = 용량 대비 데이터가 어느정도 찼을 때 사이즈 확장이 필요한지를 판단할때 쓰이는 값
- bucket size = 위에서 말한 entry size, 즉 한개의 bucket에 허용하는 entry
- m = bucket의 개수 ( 테이블크기)

fingerprint의 비트가 늘어나면 아무래도 item 자체의 식별할 수 있는 비트의 개수가 늘어남으로 그만큼 false positive는 감소한다.
그러나 위 그래프와 같이 entry의 개수가 4일때나 8일때나 95%, 98%로 load factor 자체 대세에는 큰 영향을 주지 않았다.
entry의 크기 자체는 높을수록 load factor가 높아지지만 lookup시 체크해야할 슬롯이 많아진다.
또한 bucket의 개수는 커질수록 더 큰 fingerprint가 필요함을 보였다. 충돌방지를 위해 약간 더 긴 fingerprint가 필요하다. 

따라서 6~8비트의 fingerprint만으로도 높은 채움률을 달성함을 보여줬다. 길이의 적정값을 채움으로써 효율적인 동작을 실험한 셈이다.

2. 공간 최적화 실험 (space optimization)

교차점 3%가 중요한데, 3%보다 낮은 false positive를 요구하면 cuckoo filter가 공간효율이 더 좋고, 아닌경우엔 bloom filter가 공간효율이 좋다는 의미이다. 보라색의 lower bound는 어떤 확률 자료구조라도 이 하한아래로는 갈수없음을 의미한다.

즉 3%의 false positive충족하는 지점의 경우 아이템 하나 저장에 7.2bit를 사용한다는것인데, 이 값보다 false positive를 낮추고싶다면
bloom filter는 더 많은 비트가 필요하기 때문에, cuckoo filter가 그보다 더 낮은 비트를 사용함으로 공간효율적이라는 이야기를 할 수 있다는 것이다.

이외에도 많은 내용이 있어보이나... 패스

추가로 보면 좋을 것은 redis에서도 이런 공간효율이나 삭제가능성 기능 덕분에 bloomfilter와 cuckoofilter를 구분해서 제공하고있다. error rate계산에 따라 알아서 공간할당을 해주고있으니 실제로 cuckoo filter를 사용하고싶다면 redis io 코드를 참고하는 것도 방법이다.

참고하면 좋은 자료

 

Ribbon Filter

그리고 마지막으로 Facebook에서 만든 Ribbon filter가 눈에 띄었는데, 2021년에 논문을 게제한 만큼 최신이다.

https://engineering.fb.com/2021/07/09/core-infra/ribbon-filter/

 

Ribbon filter: Practically smaller than Bloom and Xor

What the research is: The Ribbon filter is a new data structure that is more space-efficient than the popular Bloom filters that are widely used for optimizing data retrieval. One of the ways that …

engineering.fb.com

아무래도 Bloom Filter와 Xor Filter보다 더 작고 빠른 필터임을 내세우고 있는데, XOR Filter자체는 cuckoo filter와 다른것이며 삭제는 불가능하지만 공간효율이 bloom filter에 비해 좀더 좋은 성능을 좋은 자료구조이다. 따라서 Ribbon filter가 내세우고자 하는 것은 삭제가 안되는 filter들 중에서 본인이 가장 공간효율이 좋음을 이야기하는 것이다. 그러나 삭제가 불가능하기 때문에 대용량 읽기 전용 환경에서만 적합하다는 점을 꼭 짚고 넘어가자.

위의 engineering blog에서 이야기하는 장점으로는 O(1)의 쿼리시간 Bloom filter에 비해 1/3의 메모리 절약이 있다는 점이다. 
또한 성능상으로 중요한 지표인 아래 4가지가 간단한 api뒤에 감쳐져서 원하는대로 조절이 가능하다.
1. number of keys 2. memory usage 3. CPU efficiency, 4. accuracy 

Ribbon Matrix 라는 행렬을 사용하는데 이게 물리적으로 리본처럼 보이기 때문에 ribbon filter라는 이름이 붙게 되었다.

 

마무리

리본필터의 내용은 여력이 되면 나중에 좀더 공부해보려한다.
아무래도 이걸 보기위해 논문 gpt에 넣고 이해하고 다시 물어보고하면서 공부하다보니 점점 뇌절하는 느낌이라
여튼 SWE 입장에서는 확률적 데이터 구조를 사용하여 작은 공간을 가지면서도 빠른 false positive로 DB접근을 줄여 효율성을 높이는 방향이기 때문에 대용량 시스템에서 유용하게 사용할 수 있을 것으로 보여 관심을 갖게되었다. 사실 시스템설계 스터디에서 본건 Count-min sketch 에 대한 내용이었는데 이 내용도 이해한 내용들을 추가로 정리할 예정이다. 끝!

Recently, while doing a system design study group, I came across something I didn't know, so I looked into it a bit.

Probabilistic Data Structures

GPT good — the drawing doesn't quite match, but this one's the cutest so..

A data structure that trades off a bit of accuracy in results for savings in memory and performance.
Representative data structures with this property include bloom filter, count-min sketch, and so on — I honestly felt a bit regretful wondering why I didn't know about these until now. I guess that's what happens when you only study things you're interested in...

Let's use Bloom Filter as a representative example.
When we want to check whether an element exists in a Collection programmatically, we typically use methods like contains() to get the result. But to execute this contains, we needed O(n) storage space and time complexity to store that data. If we use a probabilistic data structure called Bloom Filter, the storage space needed to store elements and the time complexity to check whether an element exists are significantly reduced. However, the result may be inaccurate by the margin of error I've configured.

In other words, it's a data structure that sacrifices accuracy in exchange for space and time efficiency.

So if we map the features of various data structures with probabilistic properties to actual programming functions when accuracy is not sacrificed, we can create a table like the one below.

Data structure Feature Kotlin-style function
Bloom Filter Membership estimation contains(x)
Count-Min Sketch Frequency estimation groupingBy{ it }.eachCount()[x]
HyperLogLog Cardinality estimation distinct().count()
Top-K / HeavyKeeper Top-K item estimation groupingBy{it}.eachCount().sortedByDescending().take(k)

Looking at the Kotlin-style functions above, well... they're implemented pretty well for the features I want. But what if the number of data items inside is astronomically large — like hundreds of millions, billions, or even trillions? Can we just store them in simple server memory as arrays or hashmaps and search through them efficiently? The search time would inevitably take longer, and the memory required for storage would also increase relative to the functionality.

This is where probabilistic data structures come in — they can improve both the storage space and search time. The catch is that the results may have some margin of error.
You might wonder why use something with errors? But since they're accurate within a certain probability of error, they're perfectly usable in environments where perfect consistency isn't required.

For example, think about when a user looks up the view count of a YouTube algorithm recommendation — they don't scrutinize it down to the ones digit (excluding revenue calculations); they just need to quickly fetch that value.

Additionally, in the Spark framework that data engineers frequently use, there's a function called approx_count_distinct() which operates based on the HyperLogLog algorithm. It's not exact, but it's used to quickly calculate a fairly close count of unique values with small memory. (I clearly have so much more to study ㅠ) Apparently, when dealing with large volumes of data, if count() takes 3 hours,
using approx_count_distinct() would take only about 5 minutes.

So for large-scale systems where performance matters, it seems like probabilistic data structures could be introduced as needed, which led me to look into how these data structures work.

 

Bloom Filter: Membership Estimation

Bloom Filter is used to efficiently check whether an element exists.
This data structure was first introduced in Burton H. Bloom's 1970 paper "Space/Time Trade-offs in Hash Coding with Allowable Error." 

Paper: https://dl.acm.org/doi/10.1145/362686.362692

N = bit array / d = number of hash functions

There are three possible operations.

  • INIT: Initialize all bits in the hash area to 0.
  • ADD: Each message to be stored passes through hash functions, producing values (e.g., a₁, a₂, …, a_d), and sets the bit at each corresponding position to 1.
  • SELECT: To check whether a desired message exists in this stored state, generate d hash values for that message (e.g., a₁, a₂, …, a_d). If all bits are 1, the message is judged as exists; if even one bit is 0, it's judged as does not exist.

Understanding Bloom Filter with Diagrams

This might be hard to follow just from text, so let's look at diagrams.
Below, we've created a Bloom filter with N = 8, d = 3. For convenience, I've represented it as a 2D array, but in practice it doesn't need to be a 2D array since we only set 1 at the corresponding bit positions.

Bloom Filter with N = 8, d = 3

Below, we add messages a, b, c to this Bloom filter.
When message a passes through hash functions Hash1, Hash2, Hash3 and the resulting values are 1, 7, 5, we set 1 at the corresponding bit positions. Similarly, b and c also pass through and set their bits to 1 — and as you can see, depending on the hash functions, different messages can produce the same hash values (hash collision). Even in this case, we simply set it to 1.

Likewise, messages b and c have also been stored.

With data stored like this, you probably already know from the scenario that only message a exists, but when we want to check whether messages a and f exist, the calculation works like this:

  • a:  hash1(a) = 1,  hash2(a) = 7, hash3(a) = 5 — since all bits at these positions are set to 1, a is determined to exist.
  • f: hash1(f) = 1, hash2(f) = 3, hash3(f) = 4 — since not all bits at these positions are set to 1, f is determined to not exist.
    In this case, even though hash1(f) produced the same value as hash1(a), we verified non-existence by checking through the other hash values.

In this state, I also wanted to check the existence of message g, so I ran an additional calculation — and a situation with lots of hash collisions occurred.

  • g: hash1(g) = 1, hash2(g) = 5, hash3(g) = 3 — since all bits at these positions are set to 1, g is determined to exist.
    But wait — something's off here. We never added g, yet it was determined to exist. 

This case with g is exactly why it's called a probabilistic data structure. While the probability is very small, in the rare case where all the bit positions from the hash values of a non-existent element happen to be already set to 1, the result can incorrectly say it exists (an error occurs) even though it doesn't.

In other words, Bloom filters have a false positive characteristic. If all 1s, it might exist. If even one is 0, it definitely doesn't exist.

What the Paper Says

Intuitively, increasing the d value reduces the fraction of errors. However, if you keep increasing d with the intention of reducing errors, you'll reach a point of diminishing return after a certain threshold. Increasing d by 1 actually causes the proportion of bits set to 1 across the entire hash area to increase. The effect of adding more bits to compare is outweighed by the higher probability that a randomly chosen bit is already 1. In any case, for a given hash field size N, a theoretical minimum for the error probability exists.

Looking at the paper, I could see these intuitions analyzed with formulas (you can skip this part if you want):

Symbol Description Formula Meaning
N″ Total number of bits in the hash area - -
φ″ Proportion of bits still 0 (1 - d / N″)^n The lower this is, the more false positives
P″ False positive probability (1 - φ″)^d  
T″ Average reject time 1 / φ″ Average number of bits to read before a reject

Following these formulas, let's look at the expression for what the appropriate value of N would be and what it means:

Given the constraint that we need to read an average of T″ bits to account for reject time, this tells us how large the bit array N″ needs to be to keep the false positive probability below P″. In other words, we can see that N″ is proportional to the number of input values (n).

So if you want a low false positive probability P″ and fast T″, N″ needs to be larger.

In the paper, the Bloom filter was approached as a use case for reducing unnecessary disk accesses.

When processing 500,000 words, the assumption was that 90% could be handled simply with rule-based processing, but 10% needed to reference an actual dictionary. In this case, the 10% needs to be looked up from a dictionary on disk, but querying the disk for every single word is slow. So the scenario was: put only this 10% in a Bloom Filter, skip if it says "does not exist," and check the disk only when there's a possibility it exists.

In this scenario, when the error probability P was reduced significantly (which increased the Hash area accordingly), the actual disk access rate dropped dramatically.
Specifically, the paper showed that a Bloom Filter with an error probability of about 1.5% achieved approximately 88% accuracy.

Since this is a paper from 1970, I think this method was introduced because disk access was a significant burden back then. But it was the starting point that led to probabilistic data structures being used to this day, so I looked into it further. So the takeaway from the Bloom filter data structure is that by allowing a small margin of error (false positives), fast testing is possible even with small amounts of space. This can bring significant benefits in systems processing large-scale data.

A Question About the Bit Size N″

When I first encountered the Bloom Filter concept, the part I couldn't understand the most was: why isn't the bit value INT.MAX or LONG.MAX? Since the return value of hash functions is typically int in my programming experience, shouldn't the bit array size always be INT.MAX? — that was my very modern-programmer way of thinking. But after reading this paper (asked GPT to explain it in Korean..), I realized that back then it was an era of manual memory allocation, so that's how it was. And in practice, this N value can be adjusted regardless of the hash function's return type being Int.

int hashValue = hashFunction(x);
int columnIndex = hashValue % N;

This is because we can use modular arithmetic with whatever N value we want in order to control actual memory usage.

In practice, without using this probabilistic data structure, storing n elements would take O(n) space complexity. But with this probabilistic data structure, you only need O(d) space — the number of hash functions — to determine false positives for n elements, so I came to understand that this provides a huge spatial advantage in large-scale systems.

 

Redis Probabilistic Data Strucutures

If you actually want to use it, do you have to write the bloom filter logic yourself? Absolutely not — it's a paper from 1970, there's got to be at least one library for it. Most notably, Redis already explains and provides probabilistic data structures.

 

https://redis.io/docs/latest/develop/data-types/probabilistic/bloom-filter/

 

Bloom filter

Bloom filters are a probabilistic data structure that checks for presence of an item in a set

redis.io

When actually using a Bloom Filter with Redis, it uses an approach where you define it based on the error_rate. Unlike the examples I used above where we pre-define the d value (number of hash functions), note that Redis takes a different approach. It seems they decided that lowering the error rate matters more than having developers manually adjust the d value.

You can't use the Bloom Filter data structure with just a regular Redis server. You need an additional module, and since that's a hassle, I used a pre-built Docker image.

docker run -d --name redis-stack -p 6379:6379 redis/redis-stack-server:latest

You can easily use bloom filters through redis-cli. Using the RESERVE command, you can set the error probability and expected number of elements, and the bloom filter gets configured accordingly — you can also check its information with the BF.INFO command. As you can see from the EXISTS results, it confirms the false positive behavior (it returned true for value "a" which was never actually added). This happened because I intentionally set a high error rate and low expected element count.

So when using bloom filters with Redis, you can intentionally set a low error rate — if accuracy is important, make sure to properly configure the error_rate and capacity (expected number of elements). When checking with INFO, you can clearly see that the size is allocated larger to reduce the error rate.

Using BF.DEBUG, you can get more detailed information at the byte level.

Useful references:

 

Extended Versions of Bloom Filter 

Since it's a paper from 1970, quite a lot of extended versions have come out. On the Redis site referenced above, you can see that besides the most primitive Bloom Filter, they also offer Scalable Bloom Filter and Cuckoo Filter.
While looking around, I was happy to find a paper published by alumni from my university (Ternary Bloom Filter), and as described in the paper, I could see that these are often considered as algorithms used in network transmission. 

Structure Feature Extension Key Characteristics
Counting Bloom Filter Supports deletion Replaces each bit with a counter (1 bit → k bits).
Scalable Bloom Filter Auto-scaling size Adds a new Bloom Filter when false positive limit is exceeded
Compressed Bloom Filter Compressed storage Optimized for network transmission/storage
Cuckoo Filter Deletion + improved accuracy Based on Cuckoo Hashing. Stores fingerprints of actual keys
Ribbon Filter (Facebook) Compression + fast queries XOR-based lightweight structure, very small false positive rate
Xor Filter (Google) Fixed key set Based on perfect hashing, very fast and compact

The Counting Bloom Filter (2010 paper) replaces bits with counters instead of just setting them to 1, so it actually needs to be stored as a 2D array. On add, it increments (+1) the value at the corresponding bit position, and for deletion, it decrements (-1). At first glance, this seems similar to Count-Min Sketch, but since the use cases and query intentions differ, the internal data structures (actual N and d) can be different, so they should be distinguished.

The Scalable Bloom Filter (2007 paper) works by adding new Bloom Filters and layers to accommodate more items. These additional layers keep the same number of hash functions (d) but use a wider bit array (N). Since the same hash functions are used, it first checks across all layers to confirm non-existence before adding to the current filter. However, having more layers means using more storage, which significantly degrades performance. So even though it's "Scalable," knowing the exact number of items to include (n) is still important.

Also, the Redis Bloom Filter mentioned above provides the option to use Scalable Bloom Filter.
In fact, when I added more elements to the error_rate 0.5, capacity 10 example used above and ran INFO, the Number of filters went from 1 to 2, and the size increased from 104 to 184.

Left: Before adding elements beyond capacity. / Right: After adding elements beyond capacity

The Compressed Bloom Filter (2002 paper) works the same as a regular Bloom Filter — hashing and setting bits — but with an added compression step for the bit array.

Cuckoo Filter

While a Bloom Filter is characterized by being a bit array that sets bits to 1 at positions determined by hash functions, the Cuckoo Filter solves the problem using a bucket array. It was born out of the issues that Bloom Filters don't support deletion, and that Counting Bloom Filters do support deletion but use more memory which can cause performance degradation. The Cuckoo Filter improved on these points and actually achieved better performance as well.

The Cuckoo Filter was introduced in the 2014 paper "Cuckoo Filter: Practically Better Than Bloom."
Paper: https://www.cs.cmu.edu/~dga/papers/cuckoo-conext2014.pdf

How It Works Based on Cuckoo Hashing

It fundamentally operates based on Cuckoo Hashing—instead of setting each bit array index to 1 like in a Bloom Filter, it inserts the item's fingerprint into a bucket. When a collision occurs, it evicts and relocates.

  1. If one of the two buckets has an empty slot, insert there
  2. If both are full, evict an existing item and relocate it
  3. This process repeats until an empty space is found or the maximum retry count is reached

The fingerprint means storing only a portion of the input's hash value rather than the entire input. Thanks to this, memory usage is reduced and deletion becomes possible. (We'll look into this more below.)

To understand the insert mechanism and the basic data structure, try using the visualization tool below—it helps a lot.

https://www.lkozma.net/cuckoo_hashing_visualization/

 

Cuckoo Hashing Visualization :: Laszlo Kozma

Cuckoo Hashing Visualization Cuckoo hashing is an elegant method for resolving collisions in hash tables. It was invented in 2001 by Rasmus Pagh and Flemming Friche Rodler. It has the rare distinction of being easy to implement and efficient both in theory

www.lkozma.net

 

Let me walk through the insert scenarios based on what's in the paper. For now, think of it as inserting the item itself.

  • a) We're trying to insert item x, but buckets 2 and 6—the results of h1(x) and h2(x)—are already full. In this situation, we decide to evict item a from bucket 6. Since a has been evicted, it needs to be relocated, so we look up h1(a) and h2(a) and relocate it to bucket 4 (the other candidate bucket). However, this means c, which was in bucket 4, now needs to be evicted. Likewise, c looks up h1(c) and h2(c) and relocates to bucket 1.
  • b) After the relocation process is done, x is successfully placed in bucket 6, and as a result of all the relocations, a ends up in bucket 4, b in bucket 2, and c in bucket 1.
  • c) In a cuckoo filter, each bucket can hold b entries. Since cuckoo filters find empty space through eviction when hash collisions occur, if each bucket only had 1 entry, the eviction + relocation process would happen excessively like in scenarios a and b, causing performance degradation. By allowing multiple entries per bucket, the goal is to increase insertion success rates and reduce evictions. So even items with the same bucket index can coexist by having multiple entries within a bucket, leading to more stable performance.

Also, if you play around with the visualization tool above, you'll notice that when there are few entries, the eviction → relocation cycle can end up in an infinite loop. It's like the scenario in a and b where there's simply no space left to insert. To handle this, the cuckoo filter has a maximum retry count, and when this limit is exceeded, it breaks out of the infinite loop and signals that insertion has failed—prompting actions like increasing the number of entries per bucket or expanding the bucket size.

Fingerprint and Partial-Key Cuckoo Hashing

As briefly mentioned above, the cuckoo filter doesn't store the full data in the hash table. Instead, it stores only a fingerprint (a hash summary) to improve space efficiency. So during insertion, the bucket candidates are determined solely by the fingerprint value. The problem is, when an eviction happens, we only have the fingerprint—so we can't compute the alternate location from the original key anymore.

That's where the partial-key cuckoo hashing technique comes in.

By XORing the hash of the fingerprint with the current position, we can determine the index of the alternate bucket.

So originally we assumed there were separate h1 and h2 hash functions, but to solve the problem of inserting with only fingerprints, h2 is now derived from h1. Nevertheless, since XOR is used, it prevents buckets from being adjacent to each other, which seems to be why they considered it a good hash for reducing collisions. (If you use an 8-bit fingerprint, relocation is possible up to 256 (= 2^8) positions away.)

That said, partial-key cuckoo hashing does have its drawbacks:

  1. Reduced number of hash combinations
    Standard cuckoo hashing generates h1 and h2 with different functions, but partial-key hashing derives from the existing h1.
    This inevitably reduces the number of h1/h2 combinations and can increase the probability of collisions.
  2. Inserting different items with the same fingerprint
    Different items x and y can share the same fingerprint. So it's possible for the same fingerprint to appear multiple times within a bucket. However, if the same item is inserted 2b or more times (b = slots per bucket), both buckets can fill up completely, causing overflow.

These are the algorithms taken from the paper.

Lookup is really straightforward. You compute the two buckets and if the fingerprint exists in either one, it's considered to exist.
So there are no false negatives here (reporting something doesn't exist when it actually does). However, false positives can occur—similar to Bloom Filters—when a different item happens to hash to the same bucket.

Deletion is also simple. If the fingerprint exists in either of the two buckets, just remove it from that bucket. However, just like with lookups, since we're only comparing fingerprints, it's worth noting that it could be a different item with the same fingerprint as x. (If x and y have the same fingerprint, even after deleting x, if y is still present, it may still report that x exists.)

Results on Actual Efficiency

When using a cuckoo filter like this, there are many interdependent values to consider. Let me pull a few experimental results from the paper.

1. Analysis of Fingerprint Length

- load factor = a value used to determine when the table needs to expand based on how full it is relative to its capacity
- bucket size = the entry size mentioned above, i.e., the number of entries allowed per bucket
- m = the number of buckets (table size)

As the number of fingerprint bits increases, there are more bits available to identify each item, so false positives naturally decrease.
However, as the graph shows, whether the number of entries is 4 or 8, the load factor stays around 95% and 98%—so it doesn't have a major impact overall.
A larger entry size does increase the load factor, but it also means more slots to check during lookup.
Additionally, as the number of buckets grows, longer fingerprints are needed—slightly longer fingerprints are required to prevent collisions. 

So this shows that even with just 6–8 bit fingerprints, a high fill rate can be achieved. By finding the right fingerprint length, they demonstrated efficient operation.

2. Space Optimization Experiment

The 3% crossover point is important—if you require a false positive rate lower than 3%, the cuckoo filter is more space-efficient; otherwise, the Bloom Filter wins in space efficiency. The purple lower bound means that no probabilistic data structure can go below this limit.

In other words, at the 3% false positive threshold, storing one item takes 7.2 bits. If you want a false positive rate lower than that,
Bloom Filters need more bits, so the cuckoo filter uses fewer bits—making it more space-efficient.

There's a lot more content in the paper, but... I'll skip the rest.

One more thing worth noting is that Redis also provides both Bloom Filter and Cuckoo Filter separately, thanks to their different space efficiency and deletion capabilities. Redis automatically allocates space based on error rate calculations, so if you actually want to use a cuckoo filter, checking out the Redis IO code is a good idea.

Useful references

 

Ribbon Filter

Lastly, the Ribbon Filter created by Facebook caught my eye—it's quite recent, with the paper published in 2021.

https://engineering.fb.com/2021/07/09/core-infra/ribbon-filter/

 

Ribbon filter: Practically smaller than Bloom and Xor

What the research is: The Ribbon filter is a new data structure that is more space-efficient than the popular Bloom filters that are widely used for optimizing data retrieval. One of the ways that …

engineering.fb.com

It claims to be a smaller and faster filter than both Bloom Filter and Xor Filter. The Xor Filter itself is different from the cuckoo filter—it doesn't support deletion, but it has better space efficiency compared to Bloom Filters. So what the Ribbon Filter is really claiming is that among filters that don't support deletion, it has the best space efficiency. However, it's important to note that since deletion is not supported, it's only suitable for large-scale read-only environments.

According to the engineering blog above, the advantages include O(1) query time and 1/3 memory savings compared to Bloom Filters. 
Additionally, four key performance metrics are hidden behind a simple API and can be tuned as needed:
1. number of keys 2. memory usage 3. CPU efficiency, 4. accuracy 

It uses something called a Ribbon Matrix, and because it physically looks like a ribbon, that's how the Ribbon Filter got its name.

 

Wrapping Up

I plan to study the Ribbon Filter in more depth when I get the chance.
I've been feeding the paper into GPT, trying to understand it, asking more questions, and studying that way—and honestly, my brain started melting a bit.
Anyway, from a software engineer's perspective, I got interested because probabilistic data structures can reduce DB access through fast false-positive checks while using minimal space, which makes them really useful for large-scale systems. Actually, what I originally came across in my system design study group was Count-min Sketch—I plan to write up what I've learned about that separately. That's it!

'Develop' 카테고리의 다른 글

Solidity 치트시트  (0) 2026.04.18

댓글

Comments

Develop/AI,LLM

MCP 편하다고 막 써도 괜찮을까? | Is It Really Okay to Use MCP Just Because It's Convenient?

LLM 정말 핫하긴하다. 근데 그래서 개발자 못하려나 걱정이 있다.최근엔 chatgpt, cluad, perprexity 필요에 적극적으로 업무에도 활용하고 공부에도 정말 도움을 많이 받고있다.Junie, Copliot도 코드 짤때 정말 적극 활용하고 있는 요즘이다.실제로 linux script 실행할때나 간단한 script 코드들 짤 때. 생산성이 정말 많이 올라갔다. 예를들면 log format이 이 형태인데 grep으로 이 포맷에서 이 필드를 가진 로그가 총 몇개인지, unique 값은 몇개인지 전체 log row 중에서의 비율은 몇개인지 간단한 한줄짜리 linux command 알려달라고 할 때 일회성으로 생각없이 쓰게되는 것 같다.전반적인 구조를 고려해서 짜야하는 코드는 아직 잘 모르겠다. 구조..

MCP 편하다고 막 써도 괜찮을까? | Is It Really Okay to Use MCP Just Because It's Convenient?

728x90

LLM 정말 핫하긴하다. 근데 그래서 개발자 못하려나 걱정이 있다.
최근엔 chatgpt, cluad, perprexity 필요에 적극적으로 업무에도 활용하고 공부에도 정말 도움을 많이 받고있다.
Junie, Copliot도 코드 짤때 정말 적극 활용하고 있는 요즘이다.

실제로 linux script 실행할때나 간단한 script 코드들 짤 때. 생산성이 정말 많이 올라갔다.
예를들면 log format이 이 형태인데 grep으로 이 포맷에서 이 필드를 가진 로그가 총 몇개인지, unique 값은 몇개인지 전체 log row 중에서의 비율은 몇개인지 간단한 한줄짜리 linux command 알려달라고 할 때 일회성으로 생각없이 쓰게되는 것 같다.
전반적인 구조를 고려해서 짜야하는 코드는 아직 잘 모르겠다. 구조를 고려한건 아무래도 Junie가 잘 해주는것 같긴한데 그래도 결국 실무 코드에서는 실무자가 배포 부담을 져야하니 쉽지않다.

여튼 이런식으로 그동안은 써보기만하다가 이제는 슬슬 이것들의 동작원리나 조심해서 써야하는 부분들을 찾아봐야하려나 하는 고민이 생겼다. mcp의 등장이후로 token 연동해서 외부 api를 (mcp server) llm으로 활용하는 경우도 점점늘어나고 있어서 그렇다. 특이나 아래글들을 읽고 좀 알아봐야겠다는 생각이 들었는데

llm으로 인해 서버비가 너무 많이나온 개발자의 linkedIn 글

 

어느 날 웹 서버비가 많이 나왔어요. DDOS인 줄 알고 허겁지겁 가장 큰 트래픽 IP들 방화벽으로 차

어느 날 웹 서버비가 많이 나왔어요. DDOS인 줄 알고 허겁지겁 가장 큰 트래픽 IP들 방화벽으로 차단했는데요. 가만히 살펴보니 User-agent에 claudebot geminibot openai ... 라고 쓰여있네요. 마냥 접속을 허

kr.linkedin.com

mcp 보안에 대한 geek news 뉴스레터 글

 

MCP에서 발생할 수 있는 모든 문제들 | GeekNews

MCP는 LLM 기반 에이전트에 외부 도구 및 데이터를 통합하는 실질적 표준으로 빠르게 자리잡음보안, UX, LLM 신뢰성 문제 등 다양한 잠재적 취약점과 한계가 존재함프로토콜 자체의 설계와 인증 방

news.hada.io

이제 얕게라도 좀 알아야될때가 됐다. mcp에 대해 찾아보고 나서의 생각을 적어본것이기 때문에, 부정확할 수 있다.
더 알아야할 것들이나 정정이 필요하다면 댓글로..

 

1. MCP가 뭘까

https://modelcontextprotocol.io/introduction

 

Introduction - Model Context Protocol

Understand how MCP connects clients, servers, and LLMs

modelcontextprotocol.io

 

내생각엔 그동안 http api, tcp 등으로 통신규약을 정의해서 서버의 요청이나 응답 등으로 서비스를 제공했다면
이제 통신규약이 아니라 지정해둔 llm 키워드로 서비스를 제공하는 방식으로 세상이 변하고 있구나를 느꼈다.

만약 원하는게 github에서 내가 원하는 repo의 issue를 가져오는게 목표다 하면 그동안은
github에서 제공하는 http api 규약을 한땀한땀 맞춰서 아래와같이 요청포맷을 그들이 원하는대로 직접 넣어줬었다면.

curl -L \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer <YOUR-TOKEN>" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/repos/OWNER/REPO/issues

mcp를 사용하면 그냥 아래와 같은 prompt를 입력하면 mcp server가 위 api를 매핑해서 그 응답을 잘 내려주는 방식인 것이다.

gem-api repository의 첫번째 issue가 뭔지 알려줘.

 

실제로 github mcp server 구현을 보면 우리가 @Controller를 이용해서 endpoint를 뚫듯이 mcp server가 매핑할때 참고할만한 description을 추가해서 mcp server의 endpoint를 뚫은 모양새와 같다

https://github.com/modelcontextprotocol/servers/blob/main/src/github/index.ts

 

servers/src/github/index.ts at main · modelcontextprotocol/servers

Model Context Protocol Servers. Contribute to modelcontextprotocol/servers development by creating an account on GitHub.

github.com

   {
        name: "get_issue",
        description: "Get details of a specific issue in a GitHub repository.",
        inputSchema: zodToJsonSchema(issues.GetIssueSchema)
      },

 

실제로 안의 inputSchema의 내용을 따라가면 github api 호출을 하고있음을 알 수 있다.
결국 mcp는 llm이 사용하기 위한 @Controller를 하나 뚫어둔거라고 생각하면 된다.
어떻게? description과 name을 적당히 자연어로 잘 적어서

그래서 이제 llm + mcp를 사용하게되면 서버 프로그래밍 상으로 여러 api요청을 연쇄적으로 그때그때 인자값을 열심히 연결해서 코딩해서 넣던걸 자연어로 원하는 응답을 받을 수 있다는 장점이 생기게 된다.

요구사항이 아래와 같다고하자.

내가 가진 GitHub repository 중에 star가 가장 많은 걸 알려줘.
그리고 그 repository의 최근 커밋 수랑 contributor 수, issue 개수도 알려줘.

예전에 코딩으로 이 요구사항을 해결해야했으면
아래와 같은 수도코드를 작성하기 위해 api 명세를 확인하고.. 틀린지 아닌지 확인하고 올바른 dto 매핑인지 살펴보고 등등 귀찮았다.
사실 아래의 수도코드로는 위에 있는 요구사항을 전부 해결할 수 없다. (더 해야한다)

# 기존 방식
import requests

headers = {
    "Authorization": "Bearer <MY_TOKEN>",
    "Accept": "application/vnd.github+json"
}

# 1. 내 전체 repo 가져오기
repos = requests.get("https://api.github.com/user/repos", headers=headers).json()

# 2. 가장 star 많은 repo 찾기
top_repo = max(repos, key=lambda r: r["stargazers_count"])

# 3. 커밋 정보 가져오기
commits = requests.get(f"https://api.github.com/repos/{top_repo['full_name']}/commits", headers=headers).json()

# 4. 통계 출력
print(f"{top_repo['name']}의 커밋 수: {len(commits)}")

근데 이제 llm과 함께 mcp를 사용하게 되면 그냥 저 요구사항을 입력하면 된다.

이 요구사항을 만족하기위해 필요한 mcp server description을 알아서 판별하고 알아서 인자값을 넣어서 github api 를 호출한다.
실제로 저기 블록에 있는 search_repositories 가 호출한 mcp server 프로토콜 명을 뜻한다.

{
    name: "search_repositories",
    description: "Search for GitHub repositories",
    inputSchema: zodToJsonSchema(repository.SearchRepositoriesSchema),
  },
   case "search_repositories": {
    const args = repository.SearchRepositoriesSchema.parse(request.params.arguments);
    const results = await repository.searchRepositories(
      args.query,
      args.page,
      args.perPage
    );
    return {
      content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
    };
  }

결국 자연어에서 어떤 api를 써야하는지 찾기위한 힌트를 적기만해도 api endpoint가 뚫리는게 MCP이다

근데 이 작은 요구사항을 해결하려고 llm은 api 콜을 9개나 썼는데, 정말 이렇게까지 많이 필요한건가?
엄청 많이 하는거아닌가? 사실 개발자가 직접 코딩을 했다면 이렇게까지 많은 api를 썼을까? 이런 생각이 든다.
(근데 편하긴하다)

  • 예전 방식은 내가 어떤 API를 호출하고 있는지, 어떤 데이터를 어디로 보내고 있는지를 내가 다 컨트롤할 수 있었다.
  • MCP 방식은 내 의도를 파악한 LLM과 MCP 서버가 대신 처리해주는 구조이기 때문에, 내가 뭘 보내고 있는지 명확히 보이지 않을 수도 있다.

지금까지 설명한 이 흐름이 mcp 문서에서 설명한 architecture의 MCP Server C <-> Remote Service C 부분이다.
이걸 이해했다면 local data source에 대한것도 금방이해하리라 본다.

2. LLM + MCP가 만들어내는 보이지않는 API Call 폭발

위와같이 실제로 MCP를 통해 LLM이 API를 호출하는 과정을 추적해보면, 단일 프롬프트가 여러 개의 API 호출로 이어지는 경우를 확인할 수 있었다. 이러한 호출은 로그나 네트워크 트래픽을 분석하여 파악할 수 있으며, 예상보다 많은 호출이 발생함을 알 수 있었다.

그렇다면 기존에 서비스들이 본인들이 제공하던 open api에 더불어 mcp server 제공하게되면? 본인 서비스의 호출이 증가하게 되고
llm + mcp가 만들어내는 트래픽까지 감당해야하게 되면서 결국 서버 프로그래머들의 대규모 트래픽 관리 능력이 더더욱 중요해지는게 아닐까? (희망회로..)

한편으로는 api 호출수로 과금을 하는. 서비스라면 mcp server 호출을 유도해서 돈을 아주 잘 벌 수 있게 되겠지 싶기도 하다.

1. 캐싱전략

a. mcp server inmemory caching

LLM이 동일한 질문을 여러 번 할 수 있고, API 응답은 보통 몇 초 단위로 바뀌지 않기 때문에
응답 결과를 캐싱해두면 서버 부하를 많이 줄일 수 있을 것으로 예상한다.
이때 mcp server는 본인의 local에 있다는 점을 잘 활용하면 remote service까지 가지 않게 트래픽을 조절할 수 있다.
remote service 입장에서는 사실 기존의 클라이언트에서 local storage에 정보를 가지고 서버에 api를 호출하지 않는것과 같은 맥락

import express from "express"
import NodeCache from "node-cache" //가볍고 직관적인 in-memory 캐시 라이브러리야. TTL 기반으로 자동 만료
import axios from "axios"

const app = express()
const cache = new NodeCache({ stdTTL: 300 }) // 기본 TTL 5분

app.get("/commits/:owner/:repo", async (req, res) => {
  const { owner, repo } = req.params
  const cacheKey = `commits:${owner}/${repo}`

  // 1. 캐시에 있으면 리턴
  const cached = cache.get(cacheKey)
  if (cached) {
    console.log(`[CACHE HIT] ${cacheKey}`)
    return res.json(cached)
  }

  // 2. 외부 API 호출
  const response = await axios.get(
    `https://api.github.com/repos/${owner}/${repo}/commits`,
    {
      headers: {
        Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
        Accept: "application/vnd.github+json"
      }
    }
  )

  const data = response.data

  // 3. 캐시에 저장
  cache.set(cacheKey, data)

  console.log(`[CACHE MISS] ${cacheKey} - 저장 완료`)
  res.json(data)
})

위와 같은 코드로 api를 호출할때 caching 해두는 것 처럼 내가 만든 mcp서버가 외부 api 를 호출하는 서버라면 이 전략을 사용해서 외부 api 호출량을 줄이는 방법이 있을 것으로 보인다.

다만 이렇게 했을때 client에서 "내용이 부정확하다", "잘못된 내용으로 보인다", 등의 프롬프트가 있다면 cache reset 하고 직접 api에 호출한다던지 전략이 필요해보인다.

b. prompt caching / semantic caching

LLM에게 동일한 프롬프트를 반복해서 보냈을 때, 매번 새롭게 생각(=토큰 소모)하지 않도록, 이전 응답을 미리 캐시해두는 방식

“We do not currently cache prompts on our side. However, we recommend client-side caching if you’d like to avoid resending the same prompt multiple times.”

https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#continuing-a-multi-turn-conversation

 

Prompt caching - Anthropic

Large context caching example This example demonstrates basic prompt caching usage, caching the full text of the legal agreement as a prefix while keeping the user instruction uncached. For the first request: input_tokens: Number of tokens in the user mess

docs.anthropic.com

mcp client라고 볼수 있는 claud가 제공하고 있는 방식이다. claude나 OpenAI 같은 LLM Provider는 사실상 MCP의 client 역할을 하고 있고, 결국 client 입장에서는 llm 사용요금과도 연결되는 (돈을 아끼면서 llm을 쓰고싶은..) 부분이라서 공식적으로 지원하고 있는것으로 보인다.

요약하면 claud 사용시 아래와 내용을 추가하면 prompt cache가 활성화 된다는 이야기이다.

"cache_control": {"type": "ephemeral"}

실제로 model 로 부터 응답을 받는데 더 작은 시간이 소요된다는 예시는 아래에 있다. Example1의 non-cached api call과 cached api call을 비교하면 20s > 2s 로 많이 줄어들었음을 확인할 수 있다.

https://github.com/anthropics/anthropic-cookbook/blob/main/misc/prompt_caching.ipynb

 

anthropic-cookbook/misc/prompt_caching.ipynb at main · anthropics/anthropic-cookbook

A collection of notebooks/recipes showcasing some fun and effective ways of using Claude. - anthropics/anthropic-cookbook

github.com

 

Example2에서 응답 시간은 초기 캐시 설정 후 거의 24초에서 단 7-11초로 단축되었고, 응답 전반에 걸쳐 동일한 수준의 품질을 유지한다고한다. 7~11초의 이유는 대부분은 응답을 생성하는 데 걸리는 시간 때문이며, 캐시 breakpoints를 계속 조정하면서 입력 토큰의 거의 100%가 이후에 캐시되었기 때문에, 사용자 메시지를 거의 즉시 읽을 수 있었다고한다.

1번째 ... N 번째 시도

 

prompt_caching을 사용하면 mcp server가 효율적이게 될까? 라고하면 그건 또 상황에 따라 다르다.

1. MCP 서버가 단순 API bridge역할만 하고있다면

외부 api 응답 자체를 mcp 서버 내부에서 캐싱하고있는 것이 훨씬 효율적이다. 왜냐면 prompt를 안쓰니까.
즉, MCP 서버가 단순 API bridge역할만 하고있다면 1번과 같이 api요청에 대한 inmemory caching이 더 효과적이다.

2. mcp 서버가 여러가지 역할을 하고있다면?

지금까지 알아본 prompt caching이 효율적이려면 mcp server가 LLM prompt 결과생성까지 담당하는 구조일 때만 효율적이다.

사용자 → LLM 프롬프트 구성 → 외부 API 호출 → 응답 생성 → LLM에 전달

mcp 서버가 중간 로직과 응답 조합까지 처리하는 경우라면, 같은 프롬프트에 대해 응답을 만들 수 있기때문에 mcp 자체에서 캐싱할 수 있다.

이때 같은 프롬프트에 대한 캐싱만 아니라 의미상 비슷한 내용을 캐싱하기 위해 semantic caching을 이용하는 방법도 있는걸로 안다.
의미적 유사도를 계산하여 vector화 시키고 이것을 임베딩한다. 새로운 입력이들어왔을때 이 입력을 마찬가지로 vector화시키고 임베딩된  데이터와 유사하다면 그 응답을 반환하는 방법이라고 알고있다. 근데 직접 한다고 생각하면 머리아프다 그만알고싶다


여튼 말하고자 하는 바는 기존의 remote server api 제공자(지금의 서버개발자들)가 mcp server까지 제공하게된다면 어떤 캐싱 전략을 취하는지도 중요한 시대가 되어버렸다.
기존의 remote server 단 캐싱을 믿고 몰려드는 트래픽을 멋진 서버구조로 해결하겠어! 라는 마음가짐이 아니라
제공하는 mcp server 단에도 inmemory caching을 달아서 remote server에 몰리는 트래픽을 줄이는 방법을 고려해야한다.

근데 생각해보면 remote server 단 api 호출 수로 유저가 과금하게 만드는 구조라면 일부러 mcp server에 캐싱을 안 달 것 같기도하다.
유저입장에선 api call bridge 역할의 mcp server들의 호출들을 전부 caching해주는 caching mcp server를 사용하는게 나을 수도

 

2. 요청 제한 설정

위에 말했듯이. MCP를 쓰기 시작하면서, LLM이 단순히 한 줄 프롬프트만 받아들이는 게 아니라, 그 프롬프트를 해석해서 여러 개의 외부 API를 한꺼번에 호출하기 시작한다는 점이었다.

예전에는 사용자가 직접 API를 호출했기 때문에 “한 번에 몇 개 요청 보낼지”, “실행 시간이 얼마일지”를 어느 정도 예측할 수 있었다.
하지만 LLM은 한 문장의 목적을 이루기 위해 5개, 10개 넘는 요청을 연쇄적으로 호출할 수도 있다. 

a. rate limiting

문제는 기존 전통적인 remote server api들은 rate limiting 제한이 있다. 1초에 3개이상의 요청을 보내지 말라는 등의 요구사항으로.
고로 mcp server에서 api 콜을 보낼 때 rate limiting을 고려해야한다. ( 기존 전통적인 client들에서 고민하던 것들을 mcp server에 녹이는 느낌이 든다)

https://github.com/jwaxman19/qlik-mcp/blob/main/src/index.ts

 

qlik-mcp/src/index.ts at main · jwaxman19/qlik-mcp

An MCP server to run qlik. Contribute to jwaxman19/qlik-mcp development by creating an account on GitHub.

github.com

실제로 위 mcp서버는 Qlik Cloud API를 사용해서 시각화하는 목적을 갖고있는데, 실제 호출부의 코드를 보면 rate limiting 적용을 위해 delay를 적용해둔 걸 확인할 수 있었다.

   const data = await withRetry(async () => chartObject.getHyperCubeData('/qHyperCubeDef', [{
          qTop: startRow,
          qLeft: 0,
          qWidth: metadata.totalColumns,
          qHeight: rowCount
        }]));

        if (data?.[0]?.qMatrix) {
          allData.push(...data[0].qMatrix);
        }

        // Add delay between chunks to avoid rate limiting
        if (startRow + pageSize < rowsToFetch) {
          await delay(REQUEST_DELAY_MS);
        }
페이지네이션 하는 forloop 안에 rate limiting 코드가 들어있었음.

 

외에도 고려하면 좋을 것들로 

b. timeout

mcp server에서 외부 api를 계속 호출하는데 응답이 너무 느리게 오는 상황이라면 일부러 강제종료를 시켜서 다른 mcp tools를 이용하여 llm 이 결과를 낼수록 유도하기 때문에 timeout 설정도 잘해주는게 좋다.

c. 병렬처리 제한.

llm이 mcp tools를 이용하여 병렬로 여러 요청을 날리면 그만큼 remote server에 영향이 커지게 된다. a에서의 ratelimiting을 건다고해도 한개의 api요청에 대해서만 ratelimiting이 걸리게하는 방식으로 코드를 작성한걸 볼 수 있다.
그러나 mcp는 동시에 여러개의 tools를 사용하여 api 요청을 하게할 수 있으니 tools를 동시에 여러개 실행하게 되면 remote server에 부하가 동시에 몰릴 수도 있게되는 상황이다.

고로 java 기준은 api호출시 ExecutorService를 이용해서 고정된 쓰레드 풀로 병렬작업을 실행하도록 병렬처리 작업개수를 조절한다거나 하는 방법을 이용하는 것이다.

d. circuit breaker

나의 remote server가 죽었는데도 llm으로 인해 계속 mcp가 retry를 하게된다면? remote server에 오히려 요청이 몰리면서 c에 해둔 병렬처리 제한이 같이 걸려있다면 오히려 리소스를 사용하지 못하는 상황이 될 수 있다. 이런 상황을 막기위해 일정 횟수 이상 실패시 api 호출을 차단하는 로직들이 필요할 수 있다.


결국 써놓고 보니 mcp server를 구현하는 것은 server와 client를 동시에 제공하는것과 같은느낌이 들지 않는가? mcp server를 기존시스템에 녹여서 사용하기 위해서는 기존에 client단에서 성능을 올리기 위한 여러 트릭들을 mcp server에 적용하면 되는 느낌이다.

 

3. 보안

제일 무섭다.

 

4. 기술은 진화하지만, 본질은 크게 다르지 않다

llm이 나오고 나서 “이제 개발자는 할 일 없어지는 거 아닌가?“라는 얘기를 자주 듣는다.
우선 mcp 자체만 놓고봤을 땐, 새로운 형태의 api 프로토콜일 뿐이다. api 요청이 더 자연어에 가까워졌을 뿐

그래서 프론트에서 들어오는 요청이 자연어가 되었다고해서 그걸 처리하는 서버의 역할까지 사라지는건 아니다. 오히려 유저 요청을 더. 편하게 쓸 수 있게되었다는 점이고.

결국 서비스를 만들기 위해서는 여전히 특정 플로우를 설계해야 하고, 보안과 성능을 고려해서 캐싱도 걸고, 트래픽도 분산해야 한다.
이건 예전에도 개발자가 하던 일이었다.

이전에 pc만 쓰던시대에서 mobile도 쓰는 시대로 넘어갈때, 원래도 서버라는 개념이 있었다. 다만 mobile로 넘어가면서 그 서버들이 여러 환경에서 요청을 받을수있고 접근이 쉬워졌고 그러면서 서버에서 처리해야할 요청량들이 엄청나게 많아졌다. 따라서 서버에서 이런 요청을 처리하기 위해 많은 기존의 서버개발자들이 머리를 싸매 성능향상을 위해 여러 방법론을 제안하고 기존의 개념들을 활용한 아키텍쳐가 발생하게 된것이 아닌가?

이제 mobile app을 쓰던 시대에서 llm으로 서비스를 제공받는 시대로 넘어감에 따라서. 이전과 거의 비슷하다. 이전과 같이 유저의 서버 요청이 더 쉬워짐에 따라서 서버는 성능향상에 더 몰두하게 될 것이고, 기존의 여러 client, server 통신, 보안등에 대해서 기존의 개념들을 활용한 아키텍처가 생기고 또 서버 성능을 끌어올리기위한 노력들이 더더욱 생길 것 같다.

그래서 개발자가 사라지는게 아니라 오히려 이런 부분을 채워줄 수 있는 개발자로 나아가야할 것 같다.
결국 기존 기술들의 개념을 잘 이해하고 있는 개발자들이 LLM 시대에도 더 필요한 역할을 맡게 되지 않을까?
그래서 결국 개발공부는 해야할것 같다는 결론이 나버렸다..

근데 난 gpt 로 블로그 글은 못쓰겠다. 얘가 써주는 내용은 너무 오글거림

LLMs are really blowing up. But honestly, I'm a bit worried about whether developers will become obsolete.
Lately, I've been actively using ChatGPT, Claude, and Perplexity for work and studying — they've been incredibly helpful.
These days, I'm also heavily using Junie and Copilot when writing code.

My productivity has genuinely skyrocketed, especially when running Linux scripts or writing quick script code.
For example, when I have a log format like this and I need a one-liner Linux command to grep for how many logs have a certain field in that format, how many unique values there are, and what percentage of total log rows they represent — I just mindlessly ask for it and use it as a throwaway thing.
For code that requires thinking about the overall architecture, I'm still not so sure. Junie seems to handle structural considerations pretty well, but at the end of the day, in production code, the developer has to bear the deployment risk, so it's not that simple.

Anyway, up until now I've just been casually using these tools, but I'm starting to think it's time to look into how they actually work and what to watch out for. Especially since the arrival of MCP has led to more and more cases where people connect tokens to use external APIs (MCP servers) through LLMs. In particular, reading the articles below made me think I should dig into this a bit more.

A developer's LinkedIn post about server costs skyrocketing because of LLMs

 

One day, the web server bill was way too high. Thinking it was a DDOS attack, I frantically started blocking the top traffic IPs with the firewall

One day, the web server bill was way too high. Thinking it was a DDOS attack, I frantically blocked the top traffic IPs with the firewall. But when I looked closely, the User-agent said claudebot geminibot openai ... Just blindly allowing access

kr.linkedin.com

A GeekNews newsletter article about MCP security

 

All the Problems That Can Occur with MCP | GeekNews

MCP is rapidly becoming the de facto standard for integrating external tools and data into LLM-based agents. Various potential vulnerabilities and limitations exist, including security, UX, and LLM reliability issues. The protocol's own design and authentication approach

news.hada.io

I think it's time to learn at least the basics now. This is written after looking into MCP, so it might not be entirely accurate.
If there's anything that needs correcting or more research, let me know in the comments.

 

1. What Is MCP?

https://modelcontextprotocol.io/introduction

 

Introduction - Model Context Protocol

Understand how MCP connects clients, servers, and LLMs

modelcontextprotocol.io

 

The way I see it, until now we've been providing services through communication protocols like HTTP APIs, TCP, etc., defining request and response formats.
But now, the world is shifting toward providing services not through communication protocols, but through designated LLM keywords.

Say your goal is to fetch issues from a specific repo on GitHub. Previously,
you'd have to manually match the HTTP API specifications that GitHub provides, carefully crafting the request format exactly how they want it, like this:

curl -L \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer <YOUR-TOKEN>" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/repos/OWNER/REPO/issues

With MCP, you just type a prompt like the one below, and the MCP server maps it to the API above and returns the response nicely for you.

gem-api repository의 첫번째 issue가 뭔지 알려줘.

 

If you actually look at the GitHub MCP server implementation, it's structured similarly to how we expose endpoints using @Controller — the MCP server adds descriptions that it can reference for mapping, essentially opening up MCP server endpoints.

https://github.com/modelcontextprotocol/servers/blob/main/src/github/index.ts

 

servers/src/github/index.ts at main · modelcontextprotocol/servers

Model Context Protocol Servers. Contribute to modelcontextprotocol/servers development by creating an account on GitHub.

github.com

   {
        name: "get_issue",
        description: "Get details of a specific issue in a GitHub repository.",
        inputSchema: zodToJsonSchema(issues.GetIssueSchema)
      },

 

If you follow the inputSchema inside, you can see that it's actually making GitHub API calls under the hood.
In the end, you can think of MCP as opening up a @Controller for the LLM to use.
How? By writing the description and name appropriately in natural language.

So when you use LLM + MCP, you gain the advantage of receiving the responses you want in natural language, instead of having to chain multiple API requests together in server code, painstakingly passing arguments from one call to the next.

Let's say the requirement is something like this:

내가 가진 GitHub repository 중에 star가 가장 많은 걸 알려줘.
그리고 그 repository의 최근 커밋 수랑 contributor 수, issue 개수도 알려줘.

If you had to solve this requirement with code back in the day,
you'd have to check API specs, verify whether your code is correct, make sure the DTO mapping is right, and so on — all just to write pseudocode like the one below. It was a hassle.
And honestly, the pseudocode below doesn't even fully satisfy the requirements above. (You'd need to do more.)

# 기존 방식
import requests

headers = {
    "Authorization": "Bearer <MY_TOKEN>",
    "Accept": "application/vnd.github+json"
}

# 1. 내 전체 repo 가져오기
repos = requests.get("https://api.github.com/user/repos", headers=headers).json()

# 2. 가장 star 많은 repo 찾기
top_repo = max(repos, key=lambda r: r["stargazers_count"])

# 3. 커밋 정보 가져오기
commits = requests.get(f"https://api.github.com/repos/{top_repo['full_name']}/commits", headers=headers).json()

# 4. 통계 출력
print(f"{top_repo['name']}의 커밋 수: {len(commits)}")

But now with LLM + MCP, you just type in the requirement as-is.

It automatically figures out which MCP server descriptions are needed to fulfill the requirement, fills in the arguments on its own, and calls the GitHub API.
In fact, the search_repositories shown in that block represents the name of the MCP server protocol that was called.

{
    name: "search_repositories",
    description: "Search for GitHub repositories",
    inputSchema: zodToJsonSchema(repository.SearchRepositoriesSchema),
  },
   case "search_repositories": {
    const args = repository.SearchRepositoriesSchema.parse(request.params.arguments);
    const results = await repository.searchRepositories(
      args.query,
      args.page,
      args.perPage
    );
    return {
      content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
    };
  }

Ultimately, MCP is about opening up an API endpoint just by writing hints in natural language so the LLM can figure out which API to use.

But to handle this small requirement, the LLM made 9 API calls — do we really need that many?
Isn't that way too much? Honestly, would a developer have used this many API calls if they coded it themselves? That's what I'm thinking.
(But it is convenient, though.)

  • With the old approach, I had full control over which APIs I was calling, what data I was sending, and where it was going.
  • With the MCP approach, the LLM and MCP server handle things on your behalf based on their interpretation of your intent, which means you might not always have clear visibility into what's being sent.

The flow I've described so far corresponds to the MCP Server C <-> Remote Service C part of the architecture explained in the MCP documentation.
Once you understand this, you should be able to quickly grasp the local data source part as well.

2. The Hidden API Call Explosion Created by LLM + MCP

As shown above, when you actually trace the process of an LLM making API calls through MCP, you can see that a single prompt leads to multiple API calls. These calls can be identified by analyzing logs or network traffic, and it turns out there are far more calls happening than expected.

So what happens when existing services start offering MCP servers on top of the open APIs they already provide? Their service call volume will increase,
and they'll have to handle the additional traffic generated by LLM + MCP — which means server programmers' ability to manage large-scale traffic becomes even more important, doesn't it? (Hopeful thinking...)

On the other hand, for services that charge based on API call volume, incentivizing MCP server usage could be a great way to rake in money.

1. Caching Strategies

a. MCP Server In-Memory Caching

An LLM can ask the same question multiple times, and API responses typically don't change within a few seconds,
so caching response results should significantly reduce server load.
If you take advantage of the fact that the MCP server lives on your local machine, you can control traffic so it never even reaches the remote service.
From the remote service's perspective, it's essentially the same concept as a traditional client holding information in local storage and not making API calls to the server.

import express from "express"
import NodeCache from "node-cache" //가볍고 직관적인 in-memory 캐시 라이브러리야. TTL 기반으로 자동 만료
import axios from "axios"

const app = express()
const cache = new NodeCache({ stdTTL: 300 }) // 기본 TTL 5분

app.get("/commits/:owner/:repo", async (req, res) => {
  const { owner, repo } = req.params
  const cacheKey = `commits:${owner}/${repo}`

  // 1. 캐시에 있으면 리턴
  const cached = cache.get(cacheKey)
  if (cached) {
    console.log(`[CACHE HIT] ${cacheKey}`)
    return res.json(cached)
  }

  // 2. 외부 API 호출
  const response = await axios.get(
    `https://api.github.com/repos/${owner}/${repo}/commits`,
    {
      headers: {
        Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
        Accept: "application/vnd.github+json"
      }
    }
  )

  const data = response.data

  // 3. 캐시에 저장
  cache.set(cacheKey, data)

  console.log(`[CACHE MISS] ${cacheKey} - 저장 완료`)
  res.json(data)
})

Like the code above that caches API call results, if the MCP server you built is one that calls external APIs, you could use this strategy to reduce the number of external API calls.

However, if the client sends prompts like "the information seems inaccurate" or "this looks wrong," you'd need a strategy like resetting the cache and calling the API directly.

b. Prompt Caching / Semantic Caching

When the same prompt is repeatedly sent to an LLM, this approach pre-caches previous responses so it doesn't have to think from scratch (= consume tokens) every time.

“We do not currently cache prompts on our side. However, we recommend client-side caching if you’d like to avoid resending the same prompt multiple times.”

https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#continuing-a-multi-turn-conversation

 

Prompt caching - Anthropic

Large context caching example This example demonstrates basic prompt caching usage, caching the full text of the legal agreement as a prefix while keeping the user instruction uncached. For the first request: input_tokens: Number of tokens in the user mess

docs.anthropic.com

This is an approach provided by Claude, which can be considered an MCP client. LLM providers like Claude or OpenAI essentially play the role of MCP clients, and since from the client's perspective this directly ties into LLM usage costs (wanting to use LLMs while saving money..), they seem to officially support it.

In short, when using Claude, adding the following activates prompt caching.

"cache_control": {"type": "ephemeral"}

An example showing that it actually takes less time to get a response from the model is below. Comparing the non-cached API call and cached API call in Example 1, the time dropped significantly from 20s to 2s.

https://github.com/anthropics/anthropic-cookbook/blob/main/misc/prompt_caching.ipynb

 

anthropic-cookbook/misc/prompt_caching.ipynb at main · anthropics/anthropic-cookbook

A collection of notebooks/recipes showcasing some fun and effective ways of using Claude. - anthropics/anthropic-cookbook

github.com

 

In Example 2, the response time dropped from nearly 24 seconds to just 7-11 seconds after the initial cache setup, while maintaining the same level of quality across responses. The 7-11 seconds is mostly due to the time needed to generate the response, and by continuously adjusting the cache breakpoints, nearly 100% of input tokens were cached afterwards, which means the user message could be read almost instantly.

1st attempt ... Nth attempt

 

Does using prompt_caching make MCP servers more efficient? Well, that depends on the situation.

1. If the MCP server is only acting as a simple API bridge

It's much more efficient to cache external API responses internally within the MCP server. Because you're not using prompts at all.
In other words, if the MCP server is only acting as a simple API bridge, in-memory caching for API requests as described in option 1 is more effective.

2. What if the MCP server handles multiple responsibilities?

The prompt caching we've looked at so far is only efficient when the MCP server is structured to handle LLM prompt result generation as well.

User → LLM prompt composition → External API call → Response generation → Pass to LLM

If the MCP server handles intermediate logic and response composition, it can generate responses for the same prompt, so caching can be done at the MCP level itself.

At this point, it's not just about caching for identical prompts — I understand there's also an approach using semantic caching to cache semantically similar content.
It calculates semantic similarity, vectorizes it, and embeds it. When new input comes in, it's similarly vectorized, and if it's similar to the embedded data, the corresponding response is returned. But thinking about implementing this myself gives me a headache. I don't want to know anymore.


Anyway, the point I'm trying to make is that if existing remote server API providers (today's server developers) start providing MCP servers as well, choosing the right caching strategy has become important in this new era.
Rather than the mindset of "I'll trust the remote server-side caching and handle the flood of traffic with a fancy server architecture!",
you need to consider adding in-memory caching at the MCP server level to reduce the traffic hitting the remote server.

But then again, if the business model charges users based on remote server API call volume, they might intentionally not add caching to the MCP server.
From the user's perspective, it might be better to use a caching MCP server that caches all the calls from MCP servers acting as API call bridges.

 

2. Request Throttling

As I mentioned above, once you start using MCP, the LLM doesn't just take in a single line of prompt — it interprets that prompt and starts calling multiple external APIs all at once.

Before, users called APIs directly, so you could somewhat predict "how many requests they'd send at once" and "how long execution would take."
But an LLM might chain 5, 10, or even more requests just to fulfill a single sentence's objective. 

a. Rate Limiting

The problem is that traditional remote server APIs have rate limiting restrictions — things like "don't send more than 3 requests per second."
So when making API calls from the MCP server, you need to account for rate limiting. (It feels like we're taking the concerns that traditional clients used to deal with and baking them into the MCP server.)

https://github.com/jwaxman19/qlik-mcp/blob/main/src/index.ts

 

qlik-mcp/src/index.ts at main · jwaxman19/qlik-mcp

An MCP server to run qlik. Contribute to jwaxman19/qlik-mcp development by creating an account on GitHub.

github.com

The MCP server above is actually designed to visualize using the Qlik Cloud API, and if you look at the actual call code, you can see a delay applied for rate limiting.

   const data = await withRetry(async () => chartObject.getHyperCubeData('/qHyperCubeDef', [{
          qTop: startRow,
          qLeft: 0,
          qWidth: metadata.totalColumns,
          qHeight: rowCount
        }]));

        if (data?.[0]?.qMatrix) {
          allData.push(...data[0].qMatrix);
        }

        // Add delay between chunks to avoid rate limiting
        if (startRow + pageSize < rowsToFetch) {
          await delay(REQUEST_DELAY_MS);
        }
The rate limiting code was inside the pagination for-loop.

 

Other things worth considering include: 

b. Timeout

If the MCP server keeps calling external APIs but the responses are coming back too slowly, it's good to set proper timeouts to force-terminate and guide the LLM to produce results using other MCP tools instead.

c. Concurrency Limits

When the LLM fires off multiple requests in parallel using MCP tools, the impact on the remote server grows accordingly. Even with the rate limiting from section (a), you can see the code only applies rate limiting to individual API requests.
However, since MCP can use multiple tools simultaneously to make API requests, running several tools at once could cause a burst of load on the remote server all at once.

So in Java, for example, you'd use an ExecutorService with a fixed thread pool to control the number of concurrent tasks when making API calls.

d. Circuit Breaker

What if your remote server is down but the LLM keeps making the MCP retry? Requests pile up on the remote server, and if the concurrency limits from section (c) are also in place, you could end up in a situation where resources can't be utilized at all. To prevent this, you may need logic that blocks API calls after a certain number of failures.


When I step back and look at what I've written, doesn't implementing an MCP server feel like providing both a server and a client at the same time? To integrate an MCP server into an existing system, it feels like you just need to apply all the performance tricks that used to live on the client side to the MCP server instead.

 

3. Security

This one scares me the most.

 

4. Technology Evolves, but the Fundamentals Stay the Same

Ever since LLMs came out, I keep hearing "aren't developers going to be out of a job?"
First of all, looking at MCP by itself, it's just a new form of API protocol. API requests just got closer to natural language, that's all.

So just because the requests coming from the frontend are now in natural language doesn't mean the server's role in processing them disappears. If anything, it means users can now make requests more conveniently.

At the end of the day, to build a service you still need to design specific flows, add caching for security and performance, and distribute traffic.
This is the same work developers have always done.

Back when we transitioned from the PC-only era to the mobile era, the concept of servers already existed. But with mobile, those servers started receiving requests from multiple environments, access became easier, and the volume of requests servers had to handle skyrocketed. So server developers racked their brains to propose various methodologies for performance improvement and came up with architectures leveraging existing concepts — isn't that what happened?

Now, as we transition from the mobile app era to the era of receiving services through LLMs, it's almost identical to before. Just like before, as it becomes easier for users to make server requests, servers will focus even more on performance improvements, and architectures leveraging existing concepts around client-server communication and security will emerge, along with even more efforts to push server performance further.

So developers aren't disappearing — rather, we should be growing into developers who can fill these gaps.
Ultimately, won't developers who deeply understand the fundamentals of existing technologies be the ones needed even more in the LLM era?
So I've arrived at the conclusion that... we still need to study development after all..

The end.

But honestly, I can't write blog posts with GPT. The stuff it writes is just too cringe.

댓글

Comments

Develop/Web

Thrift 뽀개기 | Cracking Thrift

보통 서버 통신을 할 때 http 를 떠올리기 쉽다. 좀 더 가볍고, 다른 언어간의 호환이 편했으면 하는 이슈와, 당시 facebook에서 만들어서 인기가 많았다는 이유로 thrift를 서버간의 통신에서 사용하기도한다. 1. Thrift 정의하는 방법https://thrift.apache.org/ Apache Thrift - HomeThe Apache Thrift software framework, for scalable cross-language services development, combines a software stack with a code generation engine to build services that work efficiently and seamlessly between C++,..

Thrift 뽀개기 | Cracking Thrift

728x90

보통 서버 통신을 할 때 http 를 떠올리기 쉽다.
좀 더 가볍고, 다른 언어간의 호환이 편했으면 하는 이슈와, 당시 facebook에서 만들어서 인기가 많았다는 이유로 
thrift를 서버간의 통신에서 사용하기도한다.

 

1. Thrift 정의하는 방법

https://thrift.apache.org/

 

Apache Thrift - Home

The Apache Thrift software framework, for scalable cross-language services development, combines a software stack with a code generation engine to build services that work efficiently and seamlessly between C++, Java, Python, PHP, Ruby, Erlang, Perl, Haske

thrift.apache.org

먼저 thrift사용을 위해 thrift를 설치한다

$ brew update
$ brew install thrift

thrift 파일을 작성한다 (IDL). 해당 파일에 작성된 대로 해당 서버에서 thrift로 통신할 수 있는 요청 응답 포맷이 정의가 되게 된다.
IDL 작성법과 예시는 공식문서에 나와있는 example이 가장 좋은 것 같다.

https://thrift.apache.org/docs/types

 

Apache Thrift - Thrift Type system

Thrift Types The Thrift type system is intended to allow programmers to use native types as much as possible, no matter what programming language they are working in. This information is based on, and supersedes, the information in the Thrift Whitepaper. T

thrift.apache.org

 

기본 타입리스트

  • 기본타입 : bool, byte, i16, i32, i64, double, string (숫자는 전부 signed 타입이다.)
  • 특별 타입 : binary - 인코딩되지 않은 바이트 시퀀스
  • struct : oop 언어의 클래스와 동일하나 상속은 없다.
  • 컨테이너 : list, set, map<type1, type2>

위에서 말한 타입을 기반으로 https://thrift.apache.org/docs/idl 을 추가로 정의할 수 있다.

서비스

위에서 정의한 type들을 사용하여 정의된다. 인터페이스를 정의하는 것과 유사하고, 이렇게 정의된 service를 thrift 컴파일러로 컴파일을 하게되면, thrift가 정의한 서비스대로 요청 응답을 할 수 있는 클라이언트 코드 및 서버 코드를 작성해준다.

네임스페이스

작성한 thrift IDL 을 컴파일 했을 때 어떤언어로 내보낼지 설정한다. 문서를 보면 내보내기 가능한 언어 리스트가 적혀져있다. 다양한 언어로 thrift가 생성이 가능하기 때문에, 서로 다른 언어로 짜여진 서버간의 통신에 사용할 때 이점이 있다고 보는 것 같다.

 

사용법 예시를 위해 thrift DML을 아래와 같이 작성을 해보았다.

namespace java com.jyami.java.thrift.service
namespace py com.jyami.python.thrift.service

struct CommonResponse {
  1:required i32 status,
  2:optional binary bsonBody
}

struct RequestWithFlag {
  1:required binary bsonData,
  2:required bool flag
}

service PostService {

  void ping()

  CommonResponse read(1:RequestWithFlag request),

  CommonResponse save(1:binary bsonData),

  CommonResponse remove(1:binary bsonData)
}

 

2. thrift 컴파일로 코드 생성

thrift --gen <language> <Thrift filename>

 

위 명령어를 java로 실행했을 때 ( thrift --gen java app.thrift ) 위와 같은 java 파일이 자동생성되었다.

namespace에 python 도 짜두었으니, python 도 마찬가지로 생성이 가능하다.

 

3. Client, Server 코드 생성

https://thrift.apache.org/tutorial/java.html

 

Apache Thrift - Java

Java Tutorial Introduction All Apache Thrift tutorials require that you have: The Apache Thrift Compiler and Libraries, see Download and Building from Source for more details. Generated the tutorial.thrift and shared.thrift files: thrift -r --gen java tuto

thrift.apache.org

사실 공식문서에 너무 잘되어있다. java로 Client, Server 전부 작성해보았다. 
결국은 위에서 thrift gen으로 생성한 java 파일안의 클래스와 메서드들을 사용하는게 포인트이며,
Thrift 통신을 하기위한 기본적인 구현체인 TSocket, Ttransport 등등은 아래와 같이 thrift 라이브러리를 사용하면 받을 수 있다.

implementation("org.apache.thrift:libthrift:0.18.0")

3-0. 세팅

편의를 위해 client와 server를 한 디렉토리에 두었다.

하지만 핵심은 thrift 폴더 아래에 있는 app.thrift에서 생성된 java 코드 안에 있는 각종 클래스나 메서드와 같은 구현체를

비즈니스 로직이 있는 실제 클라이언트 혹은 서버의 main 코드에서 직접 사용하여 thrift로 통신을 할 수 있다는 것이다.

intellij gradle project 기준에서, 위와같이 main폴더와 thrift폴더 사이의 코드들이 서로 import 해서 사용하고, 이를 개발환경에서도 intellij 상에서 indexing을 진행한 후 사용하기 위해, 여러가지 세팅을 해주었다.

## build.gradle.kts

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    kotlin("jvm") version "1.7.10"
}

group = "org.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    testImplementation(kotlin("test"))
    implementation("org.apache.thrift:libthrift:0.18.0")
    implementation("javax.annotation:javax.annotation-api:1.3.2")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.14.1")
    implementation("de.undercouch:bson4jackson:2.13.1")
    implementation("org.slf4j:slf4j-api:1.7.25")
}

tasks.test {
    useJUnitPlatform()
}

tasks.withType<KotlinCompile> {
    kotlinOptions.jvmTarget = "1.8"
}


sourceSets {
    this.getByName("main"){
        this.java.srcDir("src/thrift/gen-java")
    }
}

 

intellij 로 실행할 때 thrift 폴더도 sourceDirectory로 인지하고 해당 폴더 안에 있는 자바 코드를 사용하기 위해 build.gradle에 sourceSets 를 설정해주었다. 또한 intellij 상에서 해당 폴더를 실제 sources로 인식할 수 있게 project structure(command + ;)에서 modules 안에 source로 잘 들어갔는지도 확인해준다.

 

3-1. Client 코드 작성

thrift gen으로 생성된 파일들의 실제 내용을 보면 알겠지만 RequestWithFlag, CommonRespones와 같은 파일은 요청 응답을 위해 정의한 구조체의 클래스만 들어있다. 즉 app.thrift에서 struct로 정의한 객체들의 파일이다.
실제로 서버로 호출을 하기위한 주된 역할을 하는 java파일은 PostService이며, Client와 Server 각각의 구현체 작성을 위한 코드는 전부 여기있다.

가이드에 따라 Client 코드를 짜고, 내 나름대로 리팩토링을 한다.

package com.jyami.sample.client

import com.jyami.java.thrift.service.CommonResponse
import com.jyami.java.thrift.service.PostService
import com.jyami.java.thrift.service.RequestWithFlag
import org.apache.thrift.protocol.TBinaryProtocol
import org.apache.thrift.transport.TSocket
import org.apache.thrift.transport.TTransport
import java.nio.ByteBuffer

fun main() {

    val sampleByte = ByteBuffer.wrap("hello".toByteArray())

    connectWithServer { transport ->
        val client = makeClient(transport)
        client.ping()
        val readResponse: CommonResponse = client.read(RequestWithFlag(sampleByte, false))
        println("read response $readResponse")
        val saveResponse: CommonResponse = client.save(sampleByte)
        println("save response $saveResponse")
        val removeResponse: CommonResponse = client.remove(sampleByte)
        println("remove response $removeResponse")
    }

}

fun connectWithServer(process: (transport: TTransport) -> Unit) {
    val transport = TSocket("localhost", 9090)
    transport.open()
    process.invoke(transport)
    transport.close()
}

fun makeClient(transport: TTransport): PostService.Client = PostService.Client(TBinaryProtocol(transport))

build.gradle에 넣어준 thrift 라이브러리에 들어있는 것들 TBinaryProtocol, TSocket, TTransport 와 같은 객체들은 전부
thrift를 이용한 socket통신을 하기위해 apache에서 제공하는 추상화된 코드들이다. 해당 객체들을 사용하여 서버와 연결지을 커넥션을 생성하고, thrift.gen으로 만들어진 java 파일들 CommonResponse, PostService, RequestWithFlag를 사용하면 쉽게 클라이언트 코드를 작성할 수 있다.

connectWithServer(), makeClient() 메서드들 각각을 각각 thrift에서 실제 socket 커넥션을 맺는 역할, 호출을 위한 client 객체를 만드는 역할로 각각 코드를 작성해주었다. 

connectWithServer로 서버와 connection을 맺고, 해당 서버에 요청을 보낼 내용들은 makeClient로 생성된 PostService.Client객체에 정의된 메서드들을 사용한다. 해당 메서드들은 app.thrift에서 정의한 service들의 메서드임을 알 수 있고, 서버에서 해당 service들에 대한 비즈니스 로직을 채워서 client에 응답을 주면 client는 해당하는 응답에 따라 클라이언트 자체 뷰를 그리기도 하고 print를 하기도하는 등 클라이언트의 요구사항을 채워준다.

 

3-2. Server 코드 작성

package com.jyami.sample.server

import com.jyami.java.thrift.service.PostService
import org.apache.thrift.server.TServer.Args
import org.apache.thrift.server.TSimpleServer
import org.apache.thrift.transport.TServerSocket

fun main(){
    val serverTransport = TServerSocket(9090)
    val postServiceImpl = PostServiceImpl()
    val server = TSimpleServer(Args(serverTransport).processor(PostService.Processor(postServiceImpl)))

    println("Starting the simple server")
    server.serve()
}

 

서버 코드도 클라이언트와 마찬가지로 thrift 라이브러리를 사용하여 ServerSocket을 열어준다. TServerSocket, TSimpleServer(http2와 같은 secure 연결이 되어있지 않은 서버) 를 활용하여 thrift 통신을 하는 서버를 구동시킨다. 

이때 해당 서버에 요청이 들어오면 어떤 처리를 할 것 인지를 지정하는 것을 .processor() 메서드로 지정하는데.
이때 처리할 Processor 구현체는 아까 app.thrift에서 생성한 PostService안에 생성이 되어있다. 

 

 

package com.jyami.sample.server

import com.jyami.java.thrift.service.CommonResponse
import com.jyami.java.thrift.service.PostService
import com.jyami.java.thrift.service.RequestWithFlag
import java.nio.ByteBuffer

class PostServiceImpl : PostService.Iface{

    override fun ping() {
        println("ping")
    }

    override fun read(request: RequestWithFlag): CommonResponse {
        println("server : read request")
        return CommonResponse()
    }

    override fun save(bsonData: ByteBuffer?): CommonResponse {
        println("server : save request")
        return CommonResponse()
    }

    override fun remove(bsonData: ByteBuffer?): CommonResponse {
        println("server : remove request")
        return CommonResponse()
    }

}

 

 

 

실제 해당 서버가 어떤 요청응답을 받는지에 대한 인터페이스는 PostService.Iface 를 이용하면 되기 때문에, 실제 서버에 작성한 프로토콜 명세에 대한 요청을 어떻게 처리할지만 정해주면 된다.
PostService.Iface를 구현한 구현체에서 각각의 프로토콜에 대한 비즈니스 로직을 길게 넣어주고 thrift파일에 정의한 프로토콜 명세에 맞게 요청값 응답값을 잘 넣어준다. 
PostService.Iface의 구현체 즉, 각 프로토콜의 동작이 정의된 비즈니스 로직을 담은 PostServiceImpl을 processor() 메서드 안에 넣어주면, 해당 thrift 서버의 동작을 정의할 수 있는 것이다.

 

3-3.  실행

위 코드대로 실제로 서버를 구동시키고 클라이언트가 2개 있다는 가정하에 Client 코드를 2번 가동시키면

서버 로그

클라이언트로부터 ping, read, save, remove, ping, read, save, remove 요청을 받아 처리하였다.

클라이언트 로그

서버에 read, save, remove 요청을 보내고 해당하는 응답을 받아 해당 응답을 직접 출력하였다.

 

3-4. 추가 잡담 - 코드 의도

thrift.app 파일에 int, long, string과 같은 다양한 타입을 정의할 수 있지만, struct에 굳이 binary를 사용한 이유는 thrift로 생성된 객체를 직접 바로 사용하고 싶지 않아서이다. 

만약 thrift로 생성된 요청 응답 객체를 직접 사용한다면, 요구사항에 따라 request에 param이 하나가 추가가 되야할 경우 그때마다 thrift.app 파일을 변경하고 그에따라 thrift gen을 이용해서 java 파일을 생성하고 생성된 java 파일에서 변경된 클래스명이나 파라이터 명에 따라 main 코드를 고쳐야한다. 

그러나 내가 원하는 요청응답 객체가 있을 때 (Post(title:String, username: String)) 위 객체를 jackson을 이용하여 byinary로 파싱하여 담으면 Post 객체 자체에 대한 요구사항을 훨씬 유연하게 처리할 수 있으면서, 가장 바깥에서 통신하는 thrift 객체는 body가 binary이기 때문에 거의 변화가 없어 위에서 말한 thrift 파일 변경 > thrift gen으로 파일생성 > 이에따른 main 코드 변경이 잦지 않게 되었기 때문에 위와 같은 코드 예시를 선택하였다.

샘플코드라서 오히려 간단해서 구분이 안갈 수 있지만, 해당 샘플 코드를 기반으로 나의 의도를 담아 비즈니스로직을 구현한 예시 소스코드가 보고싶다면 아래 링크에 넣어놨다. thrift 뽀개기 끝 ~_~

https://github.com/jyami-kim/Jyami-Java-Lab/tree/master/thrift-sample

 

GitHub - jyami-kim/Jyami-Java-Lab: 💻 Jyami의 Spring boot 및 Java 실험소 💻

💻 Jyami의 Spring boot 및 Java 실험소 💻. Contribute to jyami-kim/Jyami-Java-Lab development by creating an account on GitHub.

github.com

 

When it comes to server communication, HTTP is usually the first thing that comes to mind.
However, due to the need for something lighter and better cross-language compatibility, and partly because Facebook created it and it gained a lot of popularity, 
Thrift is also used for server-to-server communication.

 

1. How to Define Thrift

https://thrift.apache.org/

 

Apache Thrift - Home

The Apache Thrift software framework, for scalable cross-language services development, combines a software stack with a code generation engine to build services that work efficiently and seamlessly between C++, Java, Python, PHP, Ruby, Erlang, Perl, Haske

thrift.apache.org

First, install Thrift to use it.

$ brew update
$ brew install thrift

Write a Thrift file (IDL). Based on what's written in this file, the request and response formats for Thrift communication on that server will be defined.
For IDL syntax and examples, the official documentation's examples are probably the best resource.

https://thrift.apache.org/docs/types

 

Apache Thrift - Thrift Type system

Thrift Types The Thrift type system is intended to allow programmers to use native types as much as possible, no matter what programming language they are working in. This information is based on, and supersedes, the information in the Thrift Whitepaper. T

thrift.apache.org

 

Basic Type List

  • Basic types: bool, byte, i16, i32, i64, double, string (all numeric types are signed.)
  • Special type: binary - a sequence of unencoded bytes
  • struct: Same as a class in OOP languages, but without inheritance.
  • Containers: list, set, map<type1, type2>

Based on the types mentioned above, you can additionally define elements described at https://thrift.apache.org/docs/idl.

Services

Services are defined using the types described above. It's similar to defining an interface. Once a defined service is compiled with the Thrift compiler, Thrift generates both client and server code that can handle requests and responses according to the defined service.

Namespaces

This specifies which language the compiled Thrift IDL should be exported to. The documentation lists all the supported export languages. Since Thrift can generate code for a wide variety of languages, it seems to be particularly advantageous when used for communication between servers written in different languages.

 

As an example, I wrote a Thrift IDL file like below.

namespace java com.jyami.java.thrift.service
namespace py com.jyami.python.thrift.service

struct CommonResponse {
  1:required i32 status,
  2:optional binary bsonBody
}

struct RequestWithFlag {
  1:required binary bsonData,
  2:required bool flag
}

service PostService {

  void ping()

  CommonResponse read(1:RequestWithFlag request),

  CommonResponse save(1:binary bsonData),

  CommonResponse remove(1:binary bsonData)
}

 

2. Generating Code with Thrift Compiler

thrift --gen <language> <Thrift filename>

 

When running the above command for Java ( thrift --gen java app.thrift ), the Java files shown above were auto-generated.

Since we also set up Python in the namespace, Python files can be generated in the same way.

 

3. Creating Client and Server Code

https://thrift.apache.org/tutorial/java.html

 

Apache Thrift - Java

Java Tutorial Introduction All Apache Thrift tutorials require that you have: The Apache Thrift Compiler and Libraries, see Download and Building from Source for more details. Generated the tutorial.thrift and shared.thrift files: thrift -r --gen java tuto

thrift.apache.org

Honestly, the official documentation is really well done. I wrote both Client and Server in Java. 
The key point is using the classes and methods inside the Java files generated by thrift gen,
and the basic implementations for Thrift communication like TSocket, TTransport, etc. can be obtained by using the Thrift library as shown below.

implementation("org.apache.thrift:libthrift:0.18.0")

3-0. Setup

For convenience, I placed the client and server in the same directory.

But the key takeaway is that the various classes and methods — the implementations generated from app.thrift inside the thrift folder —

can be directly used in the actual client or server main code where the business logic resides, enabling Thrift communication.

In an IntelliJ Gradle project setup, I configured several settings so that the code between the main folder and the thrift folder can import and use each other, and also so that IntelliJ can index and recognize them properly in the development environment. I checked in Project Structure (Command + ;) under Modules to make sure the thrift folder was properly registered as a source.

## build.gradle.kts

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    kotlin("jvm") version "1.7.10"
}

group = "org.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    testImplementation(kotlin("test"))
    implementation("org.apache.thrift:libthrift:0.18.0")
    implementation("javax.annotation:javax.annotation-api:1.3.2")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.14.1")
    implementation("de.undercouch:bson4jackson:2.13.1")
    implementation("org.slf4j:slf4j-api:1.7.25")
}

tasks.test {
    useJUnitPlatform()
}

tasks.withType<KotlinCompile> {
    kotlinOptions.jvmTarget = "1.8"
}


sourceSets {
    this.getByName("main"){
        this.java.srcDir("src/thrift/gen-java")
    }
}

 

I configured sourceSets in build.gradle so that IntelliJ recognizes the thrift folder as a sourceDirectory and can use the Java code inside it when running. I also verified in Project Structure (Command + ;) under Modules that the folder was properly registered as a source.

 

3-1. Writing the Client Code

If you look at the actual contents of the files generated by thrift gen, you'll see that files like RequestWithFlag and CommonResponse only contain the struct classes defined for requests and responses. In other words, they are the files for the objects defined as structs in app.thrift.
The main Java file responsible for making actual server calls is PostService, and all the code needed to write both Client and Server implementations is in there.

I wrote the Client code following the guide, and then refactored it in my own way.

package com.jyami.sample.client

import com.jyami.java.thrift.service.CommonResponse
import com.jyami.java.thrift.service.PostService
import com.jyami.java.thrift.service.RequestWithFlag
import org.apache.thrift.protocol.TBinaryProtocol
import org.apache.thrift.transport.TSocket
import org.apache.thrift.transport.TTransport
import java.nio.ByteBuffer

fun main() {

    val sampleByte = ByteBuffer.wrap("hello".toByteArray())

    connectWithServer { transport ->
        val client = makeClient(transport)
        client.ping()
        val readResponse: CommonResponse = client.read(RequestWithFlag(sampleByte, false))
        println("read response $readResponse")
        val saveResponse: CommonResponse = client.save(sampleByte)
        println("save response $saveResponse")
        val removeResponse: CommonResponse = client.remove(sampleByte)
        println("remove response $removeResponse")
    }

}

fun connectWithServer(process: (transport: TTransport) -> Unit) {
    val transport = TSocket("localhost", 9090)
    transport.open()
    process.invoke(transport)
    transport.close()
}

fun makeClient(transport: TTransport): PostService.Client = PostService.Client(TBinaryProtocol(transport))

Objects like TBinaryProtocol, TSocket, and TTransport that come from the Thrift library added in build.gradle are all
abstracted code provided by Apache for socket communication using Thrift. You create a connection to the server using these objects, and then use the Java files generated by thrift gen — CommonResponse, PostService, RequestWithFlag — to easily write client code.

I wrote the connectWithServer() and makeClient() methods to handle establishing the actual socket connection with Thrift and creating the client object for making calls, respectively. 

connectWithServer establishes the connection with the server, and the actual requests sent to the server use the methods defined on the PostService.Client object created by makeClient. You can see that these methods correspond to the service methods defined in app.thrift. When the server fills in the business logic for those services and sends responses back to the client, the client can then render its own views, print output, or fulfill whatever client-side requirements it has.

 

3-2. Writing the Server Code

package com.jyami.sample.server

import com.jyami.java.thrift.service.PostService
import org.apache.thrift.server.TServer.Args
import org.apache.thrift.server.TSimpleServer
import org.apache.thrift.transport.TServerSocket

fun main(){
    val serverTransport = TServerSocket(9090)
    val postServiceImpl = PostServiceImpl()
    val server = TSimpleServer(Args(serverTransport).processor(PostService.Processor(postServiceImpl)))

    println("Starting the simple server")
    server.serve()
}

 

Just like the client, the server code also uses the Thrift library to open a ServerSocket. It uses TServerSocket and TSimpleServer (a server without secure connections like HTTP/2) to start a server that communicates via Thrift. 

To specify what processing should happen when a request comes into the server, you use the .processor() method.
The Processor implementation used here is already generated inside PostService, which was created from app.thrift. 

 

 

package com.jyami.sample.server

import com.jyami.java.thrift.service.CommonResponse
import com.jyami.java.thrift.service.PostService
import com.jyami.java.thrift.service.RequestWithFlag
import java.nio.ByteBuffer

class PostServiceImpl : PostService.Iface{

    override fun ping() {
        println("ping")
    }

    override fun read(request: RequestWithFlag): CommonResponse {
        println("server : read request")
        return CommonResponse()
    }

    override fun save(bsonData: ByteBuffer?): CommonResponse {
        println("server : save request")
        return CommonResponse()
    }

    override fun remove(bsonData: ByteBuffer?): CommonResponse {
        println("server : remove request")
        return CommonResponse()
    }

}

 

 

 

Since the interface for what requests and responses the server handles can be obtained through PostService.Iface, all you need to do is define how to process the requests for the protocol specs written on the server.
In the implementation of PostService.Iface, you add the business logic for each protocol, and make sure the request and response values match the protocol specs defined in the Thrift file. 
The implementation of PostService.Iface — that is, the business logic where each protocol's behavior is defined — PostServiceImpl, is passed into the processor() method, which defines the behavior of the Thrift server.

 

3-3.  Running It

Following the code above, after actually starting the server and running the Client code twice (assuming there are 2 clients):

Server Log

It received and processed ping, read, save, remove, ping, read, save, remove requests from the clients.

Client Log

It sent read, save, and remove requests to the server, received the corresponding responses, and printed them out.

 

3-4. Extra Thoughts - Code Intent

While you can define various types like int, long, string in the thrift.app file, the reason I deliberately used binary in the struct is that I didn't want to directly use the objects generated by Thrift. 

If you directly use the request/response objects generated by Thrift, every time a new param needs to be added to a request due to changing requirements, you'd have to modify the thrift.app file, regenerate the Java files with thrift gen, and then update the main code to reflect any changed class names or parameter names. 

However, when I have my own desired request/response object (e.g., Post(title:String, username: String)), I can parse it into binary using Jackson and pass it along. This way, I can handle changes to the Post object itself much more flexibly, and since the outermost Thrift object's body is binary, it rarely changes. This means the cycle of modifying the Thrift file → regenerating files with thrift gen → updating main code accordingly happens much less frequently. That's why I chose this code approach.

Since it's sample code, it might actually be too simple to see the distinction clearly, but if you'd like to see the source code where I implemented business logic with my intent based on this sample code, I've put it at the link below. And that's a wrap on cracking Thrift! ~_~

https://github.com/jyami-kim/Jyami-Java-Lab/tree/master/thrift-sample

 

GitHub - jyami-kim/Jyami-Java-Lab: 💻 Jyami의 Spring boot 및 Java 실험소 💻

💻 Jyami의 Spring boot 및 Java 실험소 💻. Contribute to jyami-kim/Jyami-Java-Lab development by creating an account on GitHub.

github.com

 

댓글

Comments

Develop/Kotlin

backingField와 recursive call | Backing Field and Recursive Call

Backing fieldclass User(name: String) { var name: String = name get() = name set(value) {name = value}}위와 같은 클래스가 있다고 할 때, 코틀린에서 해당 property를 get 혹은 set 할 때 재귀호출이 일어나게 된다.public fun main(){ println(User("mj").name) User("mj").name = "jyami"}위와 같이 name 프로퍼티를 접근하는 것이 getter를 부르는 것과 같기 때문에결국get() = this.get() 과 같이, getter를 부르면서 다시 getter를 호출하는 것과 같다.마찬가지로 name 프로퍼티를 할당하는 것도 set..

backingField와 recursive call | Backing Field and Recursive Call

728x90

Backing field

class User(name: String) {
    var name: String = name
        get() = name
        set(value) {name = value}

}

위와 같은 클래스가 있다고 할 때, 코틀린에서 해당 property를 get 혹은 set 할 때 재귀호출이 일어나게 된다.

public fun main(){
  println(User("mj").name)
    User("mj").name = "jyami"
}

위와 같이 name 프로퍼티를 접근하는 것이 getter를 부르는 것과 같기 때문에
결국get() = this.get() 과 같이, getter를 부르면서 다시 getter를 호출하는 것과 같다.

마찬가지로 name 프로퍼티를 할당하는 것도 setter를 부르는 것과 같아서. set()=this.set("jyami") 다시 setter를 호출하게 된다.

따라서 getter, setter 모두 본인의 필드를 참조하는 경우에는 StackOverflowException 을 발생시키게 된다. 친절하게도 intellij 에서는 recursive call 이라고 안내를 해주고 있다.

추가로 kotlin을 kotlinc를 사용하여 생성된 바이트코드를 보면 어떤 경우에 backing field가 생성되는지를 볼 수 있다. 즉 backing field가 인스턴스 변수로 생성되는 경우는 아래와 같다.

  • 하나이상의 기본 접근자를 사용하는 경우 (getter, setter)
  • 커스텀하게 만든 접근자에서 field 를 사용하는 경우.
data class HttpResponse(val body: String, var headers: Map<String, String>) {

    val hasBody: Boolean
        get() = body.isNotBlank()

    var statusCode: Int = 100
        set(value) {
            if (value in 100..599) field = value
        }
}

body, header는 기본 접근자를 이유로, statusCode는 커스텀 접근자를 이유로 생성되는데, hasBody는 그렇지 않다. (필드로 생성되지 않는다.)


javap -c -p com.kakao.talk.HttpResponse

 Compiled from "BackingField.kt"

public final class com.jyami.HttpResponse {
  private final java.lang.String body;
  private java.util.Map<java.lang.String, java.lang.String> headers;
  private int statusCode;

  // 함수들의 어셈블러 코드

Backing field

class User(name: String) {
    var name: String = name
        get() = name
        set(value) {name = value}

}

Given a class like the one above, in Kotlin, accessing the property via get or set will cause a recursive call.

public fun main(){
  println(User("mj").name)
    User("mj").name = "jyami"
}

Since accessing the name property is essentially the same as calling its getter,
it ends up being equivalent to get() = this.get() — calling the getter triggers the getter again.

Likewise, assigning to the name property is the same as calling its setter, so set()=this.set("jyami") ends up calling the setter again.

So if both the getter and setter reference their own field, it will throw a StackOverflowException. Thankfully, IntelliJ is kind enough to warn you with a "recursive call" message.

Additionally, if you look at the bytecode generated by kotlinc, you can see when a backing field is actually created. In other words, a backing field is generated as an instance variable in the following cases:

  • When at least one default accessor is used (getter, setter)
  • When a custom accessor uses the field identifier.
data class HttpResponse(val body: String, var headers: Map<String, String>) {

    val hasBody: Boolean
        get() = body.isNotBlank()

    var statusCode: Int = 100
        set(value) {
            if (value in 100..599) field = value
        }
}

body and headers get backing fields because they use default accessors, and statusCode gets one because of its custom accessor — but hasBody does not. (It is not generated as a field.)


javap -c -p com.kakao.talk.HttpResponse

 Compiled from "BackingField.kt"

public final class com.jyami.HttpResponse {
  private final java.lang.String body;
  private java.util.Map<java.lang.String, java.lang.String> headers;
  private int statusCode;

  // Assembler code for functions

댓글

Comments

Develop/Kotlin

Kotlin Void vs Unit vs Nothing | Kotlin Void vs Unit vs Nothing

Void자바의 voidjava.lang 패키지안에 있는 Void 클래스 : java의 primitive type인 void를 래핑하는 객체이다. (int wrapper인 Integer과 같다고 보면 된다.)자바에서는 void 말고 Void를 리턴해야하는 경우가 많지 않다. : 제네릭에서 Void를 사용하는 정도의 용례package java.lang;/** * The {@code Void} class is an uninstantiable placeholder class to hold a * reference to the {@code Class} object representing the Java keyword * void. * * @author unascribed * @since 1.1 */publi..

Kotlin Void vs Unit vs Nothing | Kotlin Void vs Unit vs Nothing

728x90

Void

자바의 void

java.lang 패키지안에 있는 Void 클래스 : java의 primitive type인 void를 래핑하는 객체이다. (int wrapper인 Integer과 같다고 보면 된다.)

자바에서는 void 말고 Void를 리턴해야하는 경우가 많지 않다. : 제네릭에서 Void를 사용하는 정도의 용례

package java.lang;

/**
 * The {@code Void} class is an uninstantiable placeholder class to hold a
 * reference to the {@code Class} object representing the Java keyword
 * void.
 *
 * @author  unascribed
 * @since   1.1
 */
public final
class Void {

    /**
     * The {@code Class} object representing the pseudo-type corresponding to
     * the keyword {@code void}.
     */
    @SuppressWarnings("unchecked")
    public static final Class<Void> TYPE = (Class<Void>) Class.getPrimitiveClass("void");

    /*
     * The Void class cannot be instantiated.
     */
    private Void() {}
}

Void를 코틀린에서 사용할 때

fun returnTypeAsVoidAttempt1() : Void {
    println("Trying with Void return type")
}

이때 컴파일이 되지 않는다.

Error: Kotlin: A 'return' expression required in a function with a block body ('{...}')

그래서 코틀린에서 Void 객체를 만들어서 return 해야하나. 위의 Void 클래스 정의와 같이 private constructor로 인스턴트화가 막혀있다.

따라서 위 경우에는 어쩔수 없이 Void를 nullable로 만들고 Void? null을 리턴해야한다.

fun returnTypeAsVoidAttempt1() : Void? {
    println("Trying with Void return type")
    return null
}

작동하는 솔루션이 있긴하나. java의 void와 같이 동일한 결과를 낼 수 있는 방법으로 Unit 타입이 있다. (의미 있는 것을 반환하지 않는 함수의 반환 유형)

https://www.baeldung.com/kotlin/void-type

 

Unit

https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-unit/#unit

https://kotlinlang.org/docs/functions.html#unit-returning-functions

반환값이 필요없을 때, 함수의 반환타입으로 Unit을 사용한다.

반환타입이 Unit일 경우네는, return Unit;. return; 모두 선택적으로 작성해도 된다.

fun printHello(name: String?): Unit{
    if (name != null) {
        println("hello $name")
    } else {
        println("Hi")
    }
}

java에서의 void와 대응한다. 그러나 자바에서 void는 반환 값이 없음을 의미하는 특수 타입이지만, Unit은 class로 정의된 일반타입이다.

Unit은 기본 반환 유형이므로 그리고 return 타입 명시를 안했을 때도 함수가 작동한다.

// Unit.kt
package kotlin

/**
 * The type with only one value: the `Unit` object. This type corresponds to the `void` type in Java.
 */
public object Unit {
    override fun toString() = "kotlin.Unit"
}

따라서 Unit 타입을 반환하는 함수는 return을 생략해도 암묵적으로 Unit 타입 객체를 리턴한다 (싱글턴 객체이므로 객체 생성은 하지 않는다.)

즉 기원적으로 Void는 Java를 사용할 때 만들어진 클래스, Unit은 Kotlin을 사용할 때 만들어진 클래스인 듯

 

Nothing

kotlin에서는 throw가 expression 이다. 그래서 이때 throw의 타입이 Nothing 이다.

이 타입은 값이 없으며, 도달할 수 없는 코드 위치를 표한하는데 사용된다. Nothing을 사용하면, 도달할 수 없는 코드의 위치를 컴파일단에서 체크가 가능하다.

fun fail(message: String): Nothing {
    throw IllegalArgumentException(message)
}

컴파일단 체크 덕분에 잠재적인 버그와 좋지 않은 코드로부터 확인이 가능하다. 반환 유형이 Nothing인 함수가 호출되면 이 함수 호출 이상으로 실행되지 않고, 컴파일러에서 경고를 내보낸다.

fun invokeANothingOnlyFunction() {
    fail("nothing")
        println("hello") // Unreachable code
}

또한 Nothing은 type inference (타입추론)에도 사용이 가능하다.

  • null을 사용하여 초기화된 값일 때의 타입추론
  • 구체적인 타입을 결정하는데 사용할 수 없는 경우에서의 타입추론
val x = null // "type : Nothing?"
val l = listOf(null) // "type : List<Nothing?>"

Nothing은 java에서 대응되는 개념이 없으며, 자바에서는 주로 throw 처리를 할 때 void를 사용했었다.

// Nothing.kt
package kotlin

/**
 * Nothing has no instances. You can use Nothing to represent "a value that never exists": for example,
 * if a function has the return type of Nothing, it means that it never returns (always throws an exception).
 */
public class Nothing private constructor()

마찬가지로 Nothing도 객체를 생성할 수 없다. (값을 가지지 않는다.)

따라서 Nothing이 값을 가질 수 있는 경우는 Nothing? 에서 null 이 할당되었을 때 뿐이다.

Void

Java's void

The Void class in the java.lang package: It's an object that wraps Java's primitive type void. (Think of it like Integer being the wrapper for int.)

In Java, there aren't many cases where you need to return Void instead of void — it's mostly used in generics.

package java.lang;

/**
 * The {@code Void} class is an uninstantiable placeholder class to hold a
 * reference to the {@code Class} object representing the Java keyword
 * void.
 *
 * @author  unascribed
 * @since   1.1
 */
public final
class Void {

    /**
     * The {@code Class} object representing the pseudo-type corresponding to
     * the keyword {@code void}.
     */
    @SuppressWarnings("unchecked")
    public static final Class<Void> TYPE = (Class<Void>) Class.getPrimitiveClass("void");

    /*
     * The Void class cannot be instantiated.
     */
    private Void() {}
}

Using Void in Kotlin

fun returnTypeAsVoidAttempt1() : Void {
    println("Trying with Void return type")
}

This won't compile.

Error: Kotlin: A 'return' expression required in a function with a block body ('{...}')

So should we create a Void object and return it in Kotlin? Well, as you can see from the Void class definition above, instantiation is blocked by a private constructor.

So in this case, you have no choice but to make Void nullable as Void? and return null.

fun returnTypeAsVoidAttempt1() : Void? {
    println("Trying with Void return type")
    return null
}

While this is a working solution, there's a better way to achieve the same result as Java's void — the Unit type. (It's the return type for functions that don't return anything meaningful.)

https://www.baeldung.com/kotlin/void-type

 

Unit

https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-unit/#unit

https://kotlinlang.org/docs/functions.html#unit-returning-functions

When you don't need a return value, you use Unit as the function's return type.

When the return type is Unit, both return Unit; and return; are optional — you can write either or omit them entirely.

fun printHello(name: String?): Unit{
    if (name != null) {
        println("hello $name")
    } else {
        println("Hi")
    }
}

It corresponds to void in Java. However, while void in Java is a special type meaning "no return value," Unit is a regular type defined as a class.

Since Unit is the default return type, functions work even when you don't explicitly specify a return type.

// Unit.kt
package kotlin

/**
 * The type with only one value: the `Unit` object. This type corresponds to the `void` type in Java.
 */
public object Unit {
    override fun toString() = "kotlin.Unit"
}

So a function that returns Unit implicitly returns the Unit object even if you omit the return statement. (Since it's a singleton object, no new object is created.)

In other words, it seems like Void is a class that originated from Java, while Unit is a class that originated from Kotlin.

 

Nothing

In Kotlin, throw is an expression. And the type of that throw expression is Nothing.

This type has no value and is used to mark code locations that can never be reached. By using Nothing, unreachable code locations can be checked at compile time.

fun fail(message: String): Nothing {
    throw IllegalArgumentException(message)
}

Thanks to compile-time checks, you can catch potential bugs and bad code. When a function with a return type of Nothing is called, execution never continues beyond that function call, and the compiler emits a warning.

fun invokeANothingOnlyFunction() {
    fail("nothing")
        println("hello") // Unreachable code
}

Nothing can also be used in type inference.

  • Type inference for values initialized with null
  • Type inference when a concrete type cannot be determined
val x = null // "type : Nothing?"
val l = listOf(null) // "type : List<Nothing?>"

Nothing has no corresponding concept in Java. In Java, void was typically used when handling throw.

// Nothing.kt
package kotlin

/**
 * Nothing has no instances. You can use Nothing to represent "a value that never exists": for example,
 * if a function has the return type of Nothing, it means that it never returns (always throws an exception).
 */
public class Nothing private constructor()

Likewise, Nothing cannot be instantiated either. (It holds no value.)

Therefore, the only case where Nothing can hold a value is when null is assigned to Nothing?.

댓글

Comments

Develop/Kotlin

Ktor 찍먹하기 - 간단 HTTP API 작성 | Trying Out Ktor - Writing a Simple HTTP API

ktor 공식문서 따라해 보는 중 : https://ktor.io/docs/gradle.html#create-entry-point Adding Ktor to an existing Gradle project | Ktor ktor.io나의 소스코드 : https://github.com/jyami-kim/ktor-sample 1. 서버 실행을 위한 기본 골격- ktor embeddedServer로 실행 : https://ktor.io/docs/gradle.html#create-embedded-server- ktor engineMain으로 실행 : https://ktor.io/docs/gradle.html#create-engine-maina. embeddedServer package com.exampleimpo..

Ktor 찍먹하기 - 간단 HTTP API 작성 | Trying Out Ktor - Writing a Simple HTTP API

728x90

ktor 공식문서 따라해 보는 중 : https://ktor.io/docs/gradle.html#create-entry-point

 

Adding Ktor to an existing Gradle project | Ktor

 

ktor.io

나의 소스코드 : https://github.com/jyami-kim/ktor-sample

 

1. 서버 실행을 위한 기본 골격

- ktor embeddedServer로 실행 : https://ktor.io/docs/gradle.html#create-embedded-server
- ktor engineMain으로 실행 : https://ktor.io/docs/gradle.html#create-engine-main

a. embeddedServer 

package com.example

import io.ktor.application.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*

fun main() {
    embeddedServer(Netty, port = 8080) {
        routing {
            get("/") {
                call.respondText("Hello, world!")
            }
        }
    }.start(wait = true)
}

기본 서버 실행을 위한 골격. routing에 해당하는 부분을 주로 아래와 같이 configureXXX 와 같은 형태의 플러그인 형식으로 빼서, 분리하는 패턴을 갖고있음. (코틀린의 확장함수 사용)

// com.jyami.Application

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
        configureRouting()
    }.start(wait = true)
}

// com.jyami.plugins.Routing

fun Application.configureRouting() {

    routing {
        get("/") {
            call.respondText("Hello World!")
        }
    }
}

 

위와같이 간단한 코드만 짜서 서버 RUN을 시키면? 
서버 실행 속도가 이렇게까지 빨라도 되는건가.. spring안쓰고 간단하게 서버 만들고 싶을 때 쓰면 좋을 것 같다.

 

b. engineMain (HOCON 포맷)

위 예제는 embeddedServer로 짜는 방법을 적었는데, 만약 EngineMain 방식으로 ktor 서버를 짜고 싶다면 조금 다른 방식이다. 

// main.kotlin.com.jyami

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

fun Application.module() {
    configureRouting()
}


// main.kotlin.com.jyami.plugins

fun Application.configureRouting() {
    routing {
        get("/") {
            call.respondText("Hello World!")
        }
    }
}


// resource

ktor {
    deployment {
        port = 8080
    }
    application {
        modules = [ com.jyami.ApplicationKt.module ]
    }
}

HOCON 포맷이라고한다. resources 폴더안에 application.conf 파일을 이용해서 실행시킬 포트나, entry point에대한 애플리케이션 설정을 작성한다. application.conf 관련 여러 설정들 : https://ktor.io/docs/configurations.html

 

2. gradle 설정 확인

gradle application 플러그인 : https://docs.gradle.org/current/userguide/application_plugin.html

 

The Application Plugin

This plugin also adds some convention properties to the project, which you can use to configure its behavior. These are deprecated and superseded by the extension described above. See the Project DSL documentation for information on them. Unlike the extens

docs.gradle.org

 

실행가능한 JVM 애플리케이션을 만들 수 있는 기능을 제공함. 
개발도중에 쉽게 애플리케이션을 시작할 수 있도록 해주며, TAR, AIP과 같이 애플리케이션 패키지도 지원해줌

application 플러그인 안에 java / distribution 플러그인이 모두 내장되어있다. 각각 main에 대한 소스 셋, 배포를 위한 패키징 기능을 가진 플러그인 인듯 하다.

application 플러그인을 사용할 때  configuration은 main class (i.e. entry point)를 지정하는 건 꼭 필요하다.

plugins {
    application
    kotlin("jvm") version "1.6.0"
}
application {
    mainClass.set("com.kakao.ApplicationKt")
}

실행시 intellij runner사용도 되고, ./gradlew run을 사용해도 된다.

 

3. 튜토리얼 1: HTTP API 만들기

build.gradle 분석

dependencies {
    implementation("io.ktor:ktor-server-core:$ktor_version")
    implementation("io.ktor:ktor-server-netty:$ktor_version")
    implementation("ch.qos.logback:logback-classic:$logback_version")
    implementation("io.ktor:ktor-serialization:$ktor_version")

    testImplementation("io.ktor:ktor-server-tests:$ktor_version")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}
  • ktor-server-core : ktor가 제공하는 핵심 컴포넌트 제공
  • ktor-server-netty : 네티엔진을 사용하여 서버 기능을 사용할 수 있게한다. 외부 애플리케이션 컨테이너에 의존하지 않고도 가능.
  • logback-classic : SLF4J 구현
  • ktor-serialization : 코틀린 객체를 JSON과 같은 직렬화 형태로 변환해준다. 
  • ktor-server-test-host : http 스택을 사용하지 않고도 서버 테스트가 가능함. unit test로 사용가능함

 

ConentNegotiation

fun Application.module() {
    install(ContentNegotiation){
        json()
    }
}
  • 클라이언트와 서버간의 미디어타입을 중계해준다. : Accept, Content-Type Header
  • kotlinx.serialization 라이브러리 혹은 그외의 것 (Gson, Jackson)을 사용하여 컨텐츠를 특정 포맷으로 serializing/deserializing한다. (json으로 하겠지)

ContentNegotiation 플러그인을 사용하기 위하여 애플리케이션 초기화 코드에 install 함수를 사용하여 해당하는 ConetentNegotiation 플러그인을 넘겨준다.

ktor에서는 Content-Type으로 다양한 내장 함수를 제공하고 있는데 

  • kotlinx.serializtion : JSON, Protobuf, CBOR
  • Gson : JSON
  • Jackson : JSON

그이외에 커스텀한 Content-Type을 제공하고 싶다면 아래와 같이 ContentNegotiation의 register 메서드를 사용하여 등록하면 된다

install(ContentNegotiation) {
    register(ContentType.Application.Json, CustomJsonConverter())
    register(ContentType.Application.Xml, CustomXmlConverter())
}

 

request, response 파라미터 제어

1. path parameter

get("{id}") {
    val id = call.parameters["id"] ?: return@get call.respondText(
        "Missing or malformed id",
        status = HttpStatusCode.BadRequest
    )
    val customer = customerStorage.find {it.id == id} ?: return@get call.respondText (
        "No customer with id $id",
        status = HttpStatusCode.NotFound
    )
    call.respond(customer)
}

인덱스 접근자 함수 : call.parameters["myParamName"]
요청 Path를 기본적으로 string으로 가져오게 된다.

 

2. request body

post {
    val customer = call.receive<Customer>()
    customerStorage.add(customer)
    call.respondText ("Customer stored correctly", status = HttpStatusCode.Created)
}

call.recevie<T> : 제네릭 변수를 사용하여 호출한 request body를 자동으로 코틀린 객체로 역직렬화 한다. 

동시에 여러 요청이 접근될 때 문제는 이 실습의 범위를 벗어난다 : 동시에 요청/스레드에서 액세스 할 수 있는 데이터 구조 혹은 코드를 작성하면 될 것

 

3. response body

get {
    if (customerStorage.isNotEmpty()) {
        call.respond(customerStorage)
    } else {
        call.respondText("No customers found", status = HttpStatusCode.NotFound)
    }
}
  • call.respond() : kotlin 객체를 가져와 해당 객체가 지정된 형식으로 직렬화하여 http 응답을 반환한다.
  • call.respondText() : Text를 응답으로 보낼 때의 respond를 간단하게 구현해둔 함수이다. 문자열 응답을 반환한다.

 

Routing

Route 확장함수를 이용하여 경로를 정의하였다. 이렇게 할 경우 아무래도 라우팅만 역할을 빼서 코드를 관리할 수 있어서 좋아보인다. 
Application.module에 있는 라우팅 블록 내부에 각 경로를 직접 추가할 수 있긴하지만, 파일 경로들을 그룹화하여 관리하는 것이 유지보수에 더 좋다. 

Route 확장함수만을 사용하여 경로를 정의할 경우 실제 Ktor 서버에는 적용되지 않는데, 맨 처음 ktor 시작하기에 했던 것처럼, route를 Application에 등록해주는 과정이 필요하다. 즉, Route.module에 등록된 경로들을 Application.module에 등록하는 방식으로 관리하자.

Application의 확장함수를 사용함으로써 application에 등록할 path를 route dsl을 이용하여 등록할 수 있으며, 이 dsl에는 사실상 Route 모듈에 등록된 경로를 Application에 경로를 등록하도록 도와주는 것으로 보인다.

fun Route.customerRouting() {
    route("/customer") {
    	get{}
    }
}

fun Application.registerCustomerRoutes() {
    routing {
        customerRouting()
    }
}

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
        registerCustomerRoutes()
        install(ContentNegotiation){
            json()
        }
    }.start(wait = true)
}

 

4. Ktor 테스트

ktor-server-test-host : netty를 시작하지 않고도 endpoint 테스트가 가능하다.
테스트 request를 실행하기 위한 몇가지 헬퍼 메서드가 프레임워크에서 제공되며, 그중 하나가 TestApplication 이다.

HOCON방식 : 한번에 모든 configuration이 등록가능한 구조

internal class OrderRoutingKtTest {

    @Test
    fun testGetOrder() {
        withTestApplication({ module() }) {
            handleRequest(HttpMethod.Get, "/order/2020-04-06-01").apply {
                assertEquals(
                    """{"number":"2020-04-06-01","contents":[{"item":"Ham Sandwich","amount":2,"price":5.5},{"item":"Water","amount":1,"price":1.5},{"item":"Beer","amount":3,"price":2.3},{"item":"Cheesecake","amount":1,"price":3.75}]}""",
                    response.content
                )
                assertEquals(HttpStatusCode.OK, response.status())
            }
        }

    }
    
}

HOCON 포맷인 경우에 사용이 가능한 것으로 보인다. (embeddedServer 방식으로 짜는 경우에는 httpClient를 사용한 직접 호출 방식을 이용하는 것으로 보인다 : https://ktor.io/docs/testing.html#end-to-end)

핵심은 withTestApplication인데, 테스트로 실행하려는 애플리케이션을 주입한다. (Application.module() 형태로된 메서드면 무엇이든 주입이 가능한 것으로 보인다.)

HOCON 포맷의 경우에는, main에 넣기전 main밖에 있는 Application 확장함수에 정의되어있어, 해당 애플리케이션에 대한 모든 설정이 등록된채 테스트가 가능하고, 따라서 위와 같이 module() 을 넣고 테스트를 해도 무관하다.

 

embeddedServer 방식 : 필요모듈만 configuration에 등록하여 테스트할 수 있는 구조

fun Application.testModule(){
    registerOrderRoute()
    install(ContentNegotiation){
        json()
    }
}

@Test
fun testGetOrder() {
    withTestApplication({ testModule() }) {
        handleRequest(HttpMethod.Get, "/order/2020-04-06-01").apply {
            assertEquals(
                """{"number":"2020-04-06-01","contents":[{"item":"Ham Sandwich","amount":2,"price":5.5},{"item":"Water","amount":1,"price":1.5},{"item":"Beer","amount":3,"price":2.3},{"item":"Cheesecake","amount":1,"price":3.75}]}""",
                response.content
            )
            assertEquals(HttpStatusCode.OK, response.status())
        }
    }

}

다만 embeddedServer 방식으로 withTestApplication을 작성하는 경우에는, main() 함수안에 직접 선언이 되어있어 애플리케이션에 대한 모든 테스트는 불가능하고 위에서 내가 정의한 configureRouting(), registerCustomRouting(), registerOrderRouting()과 같이 Application 확장함수 단위로 테스트가 가능하다. 

다만 여기서 주의해야할 점은 configureRouting은 단일하게 넣어도 테스트가 가능하나, registerOrderRouting, registerCustomRouting의 경우에는 불가능하다 왜냐하면 이것들을 직접 넣고 돌리게되면, Application에 등록해둔 ContentNegotiation이 빠지게 될것이고, serialize과정에 문제가 생겨서 테스트가 실패하게 될 것이다. 따라서 테스트시 두개의 configuration을 함께 등록하여 테스트해야한다.

위와같이 테스트가 가능하게 되면서, ktor을 사용할 경우에는 모듈별로, 설정별로 테스트가 손쉽게 될 것으로 보인다.
사실, embeddedServer나 HOCON이나 하나의 Application 확장함수를 만들어서 테스트하는건 같아서 입맛대로 사용하도록하자.

 

Following along with the ktor official docs: https://ktor.io/docs/gradle.html#create-entry-point

 

Adding Ktor to an existing Gradle project | Ktor

 

ktor.io

My source code: https://github.com/jyami-kim/ktor-sample

 

1. Basic Skeleton for Running a Server

- Running with ktor embeddedServer: https://ktor.io/docs/gradle.html#create-embedded-server
- Running with ktor engineMain: https://ktor.io/docs/gradle.html#create-engine-main

a. embeddedServer 

package com.example

import io.ktor.application.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*

fun main() {
    embeddedServer(Netty, port = 8080) {
        routing {
            get("/") {
                call.respondText("Hello, world!")
            }
        }
    }.start(wait = true)
}

This is the basic skeleton for running a server. The routing part is typically extracted into a plugin-style pattern like configureXXX as shown below, separating concerns. (Using Kotlin's extension functions)

// com.jyami.Application

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
        configureRouting()
    }.start(wait = true)
}

// com.jyami.plugins.Routing

fun Application.configureRouting() {

    routing {
        get("/") {
            call.respondText("Hello World!")
        }
    }
}

 

If you write just this simple code and RUN the server? 
Is it even okay for the server startup to be this fast.. Seems like it would be great for quickly spinning up a server without using Spring.

 

b. engineMain (HOCON Format)

The example above showed how to write it using embeddedServer. If you want to build a ktor server using the EngineMain approach, it's a slightly different method. 

// main.kotlin.com.jyami

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

fun Application.module() {
    configureRouting()
}


// main.kotlin.com.jyami.plugins

fun Application.configureRouting() {
    routing {
        get("/") {
            call.respondText("Hello World!")
        }
    }
}


// resource

ktor {
    deployment {
        port = 8080
    }
    application {
        modules = [ com.jyami.ApplicationKt.module ]
    }
}

It's called the HOCON format. You write the application configuration — such as the port to run on and the entry point — in an application.conf file inside the resources folder. Various settings for application.conf: https://ktor.io/docs/configurations.html

 

2. Checking the Gradle Configuration

Gradle application plugin: https://docs.gradle.org/current/userguide/application_plugin.html

 

The Application Plugin

This plugin also adds some convention properties to the project, which you can use to configure its behavior. These are deprecated and superseded by the extension described above. See the Project DSL documentation for information on them. Unlike the extens

docs.gradle.org

 

It provides the ability to create executable JVM applications. 
It makes it easy to start the application during development and also supports application packaging like TAR and ZIP.

The application plugin has both the java and distribution plugins built in. Each seems to be a plugin that provides source sets for main and packaging functionality for distribution, respectively.

When using the application plugin, specifying the main class (i.e. entry point) in the configuration is mandatory.

plugins {
    application
    kotlin("jvm") version "1.6.0"
}
application {
    mainClass.set("com.kakao.ApplicationKt")
}

You can run it using the IntelliJ runner or by using ./gradlew run.

 

3. Tutorial 1: Building an HTTP API

Analyzing build.gradle

dependencies {
    implementation("io.ktor:ktor-server-core:$ktor_version")
    implementation("io.ktor:ktor-server-netty:$ktor_version")
    implementation("ch.qos.logback:logback-classic:$logback_version")
    implementation("io.ktor:ktor-serialization:$ktor_version")

    testImplementation("io.ktor:ktor-server-tests:$ktor_version")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}
  • ktor-server-core: Provides the core components offered by ktor
  • ktor-server-netty: Enables server functionality using the Netty engine, without depending on an external application container.
  • logback-classic: SLF4J implementation
  • ktor-serialization: Converts Kotlin objects into serialized formats like JSON. 
  • ktor-server-test-host: Allows server testing without using the HTTP stack. Can be used as unit tests.

 

ContentNegotiation

fun Application.module() {
    install(ContentNegotiation){
        json()
    }
}
  • Mediates media types between the client and server: Accept, Content-Type Header
  • Uses the kotlinx.serialization library or others (Gson, Jackson) for serializing/deserializing content into a specific format (JSON in our case).

To use the ContentNegotiation plugin, you pass the ContentNegotiation plugin using the install function in the application initialization code.

Ktor provides various built-in functions for Content-Type:

  • kotlinx.serialization: JSON, Protobuf, CBOR
  • Gson: JSON
  • Jackson: JSON

If you want to provide a custom Content-Type beyond those, you can register it using ContentNegotiation's register method like this:

install(ContentNegotiation) {
    register(ContentType.Application.Json, CustomJsonConverter())
    register(ContentType.Application.Xml, CustomXmlConverter())
}

 

Controlling Request and Response Parameters

1. path parameter

get("{id}") {
    val id = call.parameters["id"] ?: return@get call.respondText(
        "Missing or malformed id",
        status = HttpStatusCode.BadRequest
    )
    val customer = customerStorage.find {it.id == id} ?: return@get call.respondText (
        "No customer with id $id",
        status = HttpStatusCode.NotFound
    )
    call.respond(customer)
}

Index accessor function: call.parameters["myParamName"]
It retrieves the request path as a string by default.

 

2. request body

post {
    val customer = call.receive<Customer>()
    customerStorage.add(customer)
    call.respondText ("Customer stored correctly", status = HttpStatusCode.Created)
}

call.receive<T>: Uses a generic type parameter to automatically deserialize the request body into a Kotlin object. 

Handling concurrent request access is beyond the scope of this tutorial — you'd just need to write data structures or code that can handle simultaneous requests/threads.

 

3. response body

get {
    if (customerStorage.isNotEmpty()) {
        call.respond(customerStorage)
    } else {
        call.respondText("No customers found", status = HttpStatusCode.NotFound)
    }
}
  • call.respond(): Takes a Kotlin object, serializes it into the specified format, and returns it as an HTTP response.
  • call.respondText(): A convenience function for sending text as a response. Returns a string response.

 

Routing

Routes are defined using Route extension functions. This approach is nice because it lets you separate and manage just the routing logic on its own. 
While you could add each route directly inside the routing block in Application.module, grouping file paths together is better for maintainability. 

If you only define routes using Route extension functions, they won't actually be applied to the Ktor server. As we did in the initial ktor setup, you need to register the routes with the Application. In other words, manage it by registering the routes defined in Route.module into Application.module.

By using Application extension functions, you can register paths to the application using the route DSL. This DSL essentially helps register the routes defined in the Route module into the Application.

fun Route.customerRouting() {
    route("/customer") {
    	get{}
    }
}

fun Application.registerCustomerRoutes() {
    routing {
        customerRouting()
    }
}

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
        registerCustomerRoutes()
        install(ContentNegotiation){
            json()
        }
    }.start(wait = true)
}

 

4. Testing in Ktor

ktor-server-test-host: Allows endpoint testing without starting Netty.
The framework provides several helper methods for executing test requests, and one of them is TestApplication.

HOCON approach: A structure where all configurations can be registered at once

internal class OrderRoutingKtTest {

    @Test
    fun testGetOrder() {
        withTestApplication({ module() }) {
            handleRequest(HttpMethod.Get, "/order/2020-04-06-01").apply {
                assertEquals(
                    """{"number":"2020-04-06-01","contents":[{"item":"Ham Sandwich","amount":2,"price":5.5},{"item":"Water","amount":1,"price":1.5},{"item":"Beer","amount":3,"price":2.3},{"item":"Cheesecake","amount":1,"price":3.75}]}""",
                    response.content
                )
                assertEquals(HttpStatusCode.OK, response.status())
            }
        }

    }
    
}

This appears to be usable with the HOCON format. (For the embeddedServer approach, it seems you'd use a direct call method with httpClient instead: https://ktor.io/docs/testing.html#end-to-end)

The key part is withTestApplication, which injects the application you want to run for testing. (It looks like any method in the form of Application.module() can be injected.)

With the HOCON format, since the Application extension function is defined outside of main before being put into main, all configurations for that application are registered and can be tested. So testing with module() as shown above works just fine.

 

embeddedServer approach: A structure where you can register only the needed modules in the configuration for testing

fun Application.testModule(){
    registerOrderRoute()
    install(ContentNegotiation){
        json()
    }
}

@Test
fun testGetOrder() {
    withTestApplication({ testModule() }) {
        handleRequest(HttpMethod.Get, "/order/2020-04-06-01").apply {
            assertEquals(
                """{"number":"2020-04-06-01","contents":[{"item":"Ham Sandwich","amount":2,"price":5.5},{"item":"Water","amount":1,"price":1.5},{"item":"Beer","amount":3,"price":2.3},{"item":"Cheesecake","amount":1,"price":3.75}]}""",
                response.content
            )
            assertEquals(HttpStatusCode.OK, response.status())
        }
    }

}

However, when writing withTestApplication with the embeddedServer approach, since everything is declared directly inside the main() function, you can't test the entire application at once. Instead, you can test at the Application extension function level — like configureRouting(), registerCustomRouting(), and registerOrderRouting() that I defined above. 

One thing to watch out for here: configureRouting can be tested on its own, but registerOrderRouting and registerCustomRouting cannot. That's because if you plug them in directly and run them, the ContentNegotiation registered in the Application will be missing, causing issues with the serialization process and making the tests fail. So you need to register both configurations together when testing.

With testing set up like this, using ktor makes it look easy to test by module and by configuration.
Honestly, whether you use embeddedServer or HOCON, it's the same in that you create one Application extension function and test with it, so just use whichever you prefer.

 

댓글

Comments