반응형

새로운 MCP Spec 을 유즈케이스를 포함하여 이해하기 쉽게 정리. 물론 내가 이해하기 쉽게 gemini 의 도움을 받았다.

2026년 7월 28일에 발표된 Model Context Protocol(MCP) 사양 업데이트의 핵심은 '상태 유지(Stateful) 양방향 프로토콜에서 상태 비저장(Stateless) 요청/응답 프로토콜로의 전환'이다.

https://blog.modelcontextprotocol.io/posts/2026-07-28/ 기사에서 설명된 주요 변경 사항들을 바탕으로, 각 케이스별 이전 방식과 바뀐 방식을 유즈케이스로 정리하였다.

1. 핸드셰이크 및 세션 제거 (No handshake or sessions)

이번 업데이트의 가장 큰 변화로, 연결을 유지하는 세션 개념이 완전히 사라졌다.

  • 이전 방식 (Stateful): 클라이언트는 서버와 통신하기 전 반드시 initialize / initialized 핸드셰이크 과정을 거쳐야 했으며, 이후 모든 요청에 Mcp-Session-Id 헤더를 포함하여 세션을 유지해야 했다. 로드밸런서 환경에서는 특정 클라이언트가 동일한 서버 인스턴스에만 붙도록 'Sticky Session' 설정이 필수였다.
  • 바뀐 방식 (Stateless): 세션과 초기화 과정이 폐지되었다. 대신 모든 단일 요청이 독립적이며, 프로토콜 버전, 클라이언트 신원, 권한 등을 _meta 필드와 HTTP 헤더에 담아 보낸다. 아무 로드밸런서(Round-robin)나 거쳐 아무 서버 인스턴스에 요청이 도달해도 정상 처리된다. 서버가 상태를 유지해야 한다면 도구(Tool) 실행 결과로 명시적인 핸들(Handle) 값을 반환하여 클라이언트가 다음 요청 시 전달하게 한다.
// 바뀐 방식의 HTTP 요청 예시
POST /mcp HTTP/1.1
Host: api.example.com
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "search",
    "arguments": {
      "q": "2026 company OKRs"
    },
    "_meta": {
      "io.modelcontextprotocol/clientInfo": {
        "name": "my-enterprise-agent",
        "version": "1.0"
      }
    }
  }
}

Mcp-Session-Id에 어떤 정보들이 저장되었고, 왜 그것이 필요했었는지 이해가 안 가는 것은 지극히 정상이다. 특히 클라우드 네이티브와 쿠버네티스 환경에 익숙하다면, '왜 굳이 HTTP 통신에 세션을 유지해서 상태(Stateful)를 만들었지?' 하고 의문을 가지시는 것이 당연하다.

이전의 MCP(Model Context Protocol)는 주로 LLM(AI 에이전트)과 서버 간의 지속적인 대화형 상호작용을 전제로 설계되었기 때문에, 서버 메모리에 특정 세션(현재 AI와의 대화 흐름)에 종속된 임시 상태들을 유지하려 했다.

Mcp-Session-Id를 키(Key)로 삼아 서버 메모리(또는 Redis 등)에 저장해두었던 '특별한 정보'들의 대표적인 예시는 다음과 같다.

유즈케이스 예제:

1. 리소스 구독 및 변경 알림 (Resource Subscriptions)

가장 대표적인 세션 사용 사례이다. AI 에이전트가 특정 파일이나 데이터베이스의 실시간 변경 사항을 추적해야 할 때 사용되었다.

  • 저장된 정보 예: Session-Id: 1234는 현재 github/repo/issue#5 리소스를 구독 중임.
  • 어떻게 썼나: 서버는 백그라운드에서 이슈 업데이트를 모니터링하다가 변경이 발생하면, 메모리에서 해당 리소스를 구독 중인 Mcp-Session-Id를 찾아 열려있는 Streamable HTTP 스트림을 통해 AI에게 알림을 쏴주었다.
  • 문제점: 쿠버네티스 환경에서 파드(Pod)가 재시작되거나 오토스케일링으로 인해 트래픽이 다른 노드로 라우팅되면, 구독 정보(세션)가 날아가서 AI가 더 이상 알림을 받지 못하는 치명적인 단점이 있었다.

2. 다중 단계 작업의 임시 컨텍스트 (Multi-step Temporary Workspace)

AI가 도구를 여러 번 호출하여 복잡한 작업을 수행할 때, 이전 작업의 중간 결과물을 서버가 기억해두는 용도이다.

  • 저장된 정보 예: Session-Id: 5678의 임시 작업 공간(Temp Directory 경로, 또는 인메모리 데이터프레임).
  • 어떻게 썼나:
    1. AI가 "대용량 CSV 파일 다운로드해서 필터링해줘" (데이터 MCP 서버의 도구 A 호출)
    2. 서버는 다운로드 후 필터링한 데이터를 Session-Id: 5678 공간에 임시 저장하고 성공 여부만 반환.
    3. AI가 "그 데이터로 차트 그려줘" (데이터 MCP 서버의 도구 B 호출)
    4. 서버는 요청 헤더의 Session-Id: 5678를 보고 앞서 저장해둔 임시 데이터를 꺼내어 차트를 생성.
  • 상태 비저장(Stateless)으로의 변화: 이제 세션이 없으므로, 도구 A는 결과를 임시 저장한 뒤 handle_id (예: "temp_file_999")를 반환해야 한다. AI는 도구 B를 호출할 때 인자(Argument)로 handle_id: "temp_file_999"를 명시적으로 넘겨주는 방식으로 바뀌었다.

  • Tool Schema (도구 정의 예시)
{
  "name": "draw_chart",
  "description": "이전 작업에서 가공한 데이터로 차트를 그린다.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "workspace_token": { // 개발자가 임의로 정의한 핸들 이름
        "type": "string",
        "description": "이전 데이터 전처리 작업에서 반환받은 임시 토큰값을 넣는다."
      }
    },
    "required": ["workspace_token"]
  }
}

 

3. 페이지네이션 커서 및 순차적 조회 상태 (Pagination & Cursor)

대량의 리소스(예: 수천 개의 로그 라인, 수백 개의 프롬프트 목록)를 여러 번에 걸쳐 가져올 때, '어디까지 읽었는지'를 기억하는 용도이다.

  • 저장된 정보 예: Session-Id: 9012는 logs/error.log의 '1,500번째 라인'까지 읽었음.
  • 어떻게 썼나: AI가 "다음 목록 줘"라고 요청 바디 없이 호출해도, 서버는 세션 ID를 조회하여 1,501번째 라인부터 응답해 주었다.
  • 문제점: 전형적인 안티 패턴이다. RESTful API처럼 커서 토큰(Cursor Token)이나 Offset을 클라이언트가 명시적으로 보내는 것이 맞다. 이번 업데이트로 이 역시 클라이언트가 토큰을 관리하도록 변경되었다.

4. 진행 중인 트랜잭션 관리 (Active Transactions)

데이터베이스의 Transaction이나, 인프라 프로비저닝 시 Lock을 걸어두는 행위를 세션과 묶어 관리했다.

  • 저장된 정보 예: Session-Id: 3456이 DB에 BEGIN TRANSACTION을 걸어두었음.
  • 어떻게 썼나: AI가 특정 데이터를 수정하고 최종 확인(Commit)을 하기 전까지, 세션 ID를 기반으로 해당 DB 커넥션이나 Lock을 유지했다. 만약 클라이언트와의 세션 연결이 끊어지면(네트워크 단절 등), 서버가 이를 감지하고 트랜잭션을 롤백(Rollback)하는 용도로 사용되었다.

클라우드 및 쿠버네티스 인프라를 다루시는 입장에서는 "왜 L7 로드밸런서에서 Sticky Session(Session Affinity)을 강제해야만 했는가?"에 대한 해답이 바로 이 Mcp-Session-Id 때문이었다.

이전 스펙에서는 위와 같은 상태들(구독 정보, 임시 컨텍스트 등)이 특정 서버 파드(Pod)의 메모리에 결합되어 있었기 때문에, 라운드 로빈(Round-robin)으로 다른 파드에 요청이 들어가면 "존재하지 않는 세션"이라며 에러가 발생했다. 이를 해결하려면 별도의 Redis 같은 외부 저장소를 강제해야만 했다.

이번 2026년 7월 28일 업데이트로 이런 암묵적인 세션 의존성을 모두 걷어내고, 상태 관리를 클라이언트에게 명시적으로 위임하거나 응답 데이터 자체에 포함(Self-describing)시킴으로써, 어떤 로드밸런서를 거쳐 어떤 파드에 요청이 떨어져도 동작하는 진정한 클라우드 네이티브 아키텍처로 진화했다고 볼 수 있다.

 

2. 다중 왕복 요청 (Multi Round-Trip Requests, MRTR)

서버가 클라이언트(또는 사용자)에게 역으로 정보를 요청해야 할 때 사용하는 방식이 변경되었다.

  • 이전 방식 (Streams): 서버가 사용자에게 확인을 받거나(Elicitation) 추가 입력을 받아야 할 때, 항상 열려 있는 양방향 스트림(Streamable HTTP 또는 WebSocket)을 통해서만 서버발 요청(elicitation/create 등)을 보낼 수 있었다.
  • 바뀐 방식 (MRTR): Stateless 환경에 맞게 스트림이 불필요해졌다. 서버가 추가 입력이 필요하면 응답으로 resultType: "input_required"를 반환한다. 클라이언트는 사용자에게 입력을 받은 뒤, 원래 보냈던 요청 바디에 inputResponses를 추가하여 재시도(Retry)한다.

유즈케이스 예제: 데이터베이스 테이블 삭제 권한 확인 (Elicitation)

// 1. 클라이언트의 최초 요청 (테이블 삭제)
{
  "method": "tools/call",
  "params": { "name": "drop_table", "arguments": { "table": "users" } }
}

// 2. 서버의 응답 (바뀐 방식: 확인 필요)
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "input_required",
    "requests": [
      {
        "id": "req-123",
        "type": "confirmation",
        "message": "정말로 'users' 테이블을 삭제하시겠습니까?"
      }
    ]
  }
}

// 3. 클라이언트의 재시도 요청 (사용자 승인 완료)
{
  "method": "tools/call",
  "params": {
    "name": "drop_table", 
    "arguments": { "table": "users" },
    "inputResponses": {
      "req-123": { "approved": true }
    }
  }
}

 

3. 헤더 기반 라우팅 (Header-based routing)

API 게이트웨이나 방화벽(WAF)에서의 라우팅 및 제어 방식이 개선되었다.

  • 이전 방식: 라우터나 게이트웨이가 특정 도구(Tool)에 대해 트래픽을 제어(예: 과금, 속도 제한)하려면, 무거운 JSON 바디를 직접 파싱하여 method나 name 값을 찾아내야 했다.
  • 바뀐 방식: 모든 HTTP 요청 헤더에 Mcp-Method 와 Mcp-Name 이 필수로 포함된다. 게이트웨이는 바디를 열어볼 필요 없이 헤더만 보고 라우팅이나 Rate Limiting을 수행할 수 있다.

유즈케이스 예제: 리소스 소모가 큰 이미지 생성 도구에 대한 API 게이트웨이 속도 제한

// 게이트웨이는 아래 헤더만 읽고 "image_generator" 도구에 대해 초당 10회 제한 룰을 즉각 적용할 수 있음
POST /mcp HTTP/1.1
Mcp-Method: tools/call
Mcp-Name: image_generator

 

4. 리스트 결과 캐싱 지원 (List results are cacheable)

목록을 불러오는 API의 효율성이 극대화되었다.

  • 이전 방식: tools/list, prompts/list 등을 호출할 때 캐시 기준이 없어, 클라이언트는 서버에 재연결할 때마다 도구 목록을 처음부터 다시 받아오거나 임의로 자체 캐싱을 해야 했다.
  • 바뀐 방식: 목록 반환 응답에 명시적으로 ttlMs(캐시 유지 시간)와 cacheScope가 포함된다. 클라이언트는 이를 바탕으로 불필요한 재요청을 줄일 수 있다.

유즈케이스 예제: 툴 카탈로그 캐싱

// 서버의 응답 예시
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      { "name": "calculator", "description": "기본 계산기" },
      { "name": "weather", "description": "날씨 조회" }
    ],
    "ttlMs": 3600000,  // 1시간 동안 캐시 유효
    "cacheScope": "public" 
  }
}

 

 

5. 인가(Authorization) 및 보안 강화

보안 취약점 패치 및 OAuth 호환성이 강화되었다.

  • 이전 방식: DCR(Dynamic Client Registration)을 주로 사용했으며, 데스크탑 앱이나 CLI 환경에서 OAuth 진행 시 redirect_uri 오류가 자주 발생했다. 또한 인가 서버 혼동 공격에 취약할 수 있었다.
  • 바뀐 방식: RFC 9207에 따라 iss 파라미터를 반환 및 검증하며, CLI 환경의 리다이렉트 에러 해결을 위해 application_type을 설정한다. 궁극적으로 DCR은 더 이상 권장되지 않으며(Deprecated), 정적인 CIMD(Client ID Metadata Documents) 체계로 전환된다.

엔터프라이즈 환경에서 인증/인가 아키텍처를 설계하실 때 가장 까다로운 부분 중 하나가 표준 OAuth 2.0 스펙과의 호환성 및 보안 취약점 방어이다. 이번 MCP 2026-07-28 업데이트의 인가(Authorization) 섹션은 바로 이 부분을 글로벌 표준 스펙(RFC)에 맞게 끌어올린 것이다.

기사에서 언급된 핵심 변경 사항인 1) 네이티브 앱(CLI/Desktop) 리다이렉트 호환성 강화 2) 인가 서버 혼동 공격(Mix-up Attack) 방어(RFC 9207)를 중심으로, 이전과 바뀐 방식을 상세히 비교했다. (유즈케이스 포함)

강화 포인트 1: CLI/데스크탑 환경의 OAuth 리다이렉트 문제 해결 (application_type)

로컬 환경에서 실행되는 AI 에이전트(CLI나 데스크탑 앱)가 서버의 리소스에 접근하려면 OAuth 2.0 인가 코드(Authorization Code) 흐름을 타야 한다. 이때 코드를 돌려받기 위해 로컬호스트([http://127.0.0.1](http://127.0.0.1))를 리다이렉트 URI로 사용하게 되는데, 엄격한 보안을 적용하는 엔터프라이즈 인가 서버들은 HTTPS가 아니라는 이유로 이를 거부하는 문제가 있었다.

❌ 이전 방식 흐름 (에러 발생)

인가 서버가 해당 클라이언트가 '웹 서버'인지 '로컬 앱'인지 구분하지 못해 발생하는 문제이다.

  1. [AI 에이전트] ➔ [인가 서버]: 동적 클라이언트 등록(DCR) 요청
    • Payload: {"redirect_uris": ["[http://127.0.0.1:8080/callback](http://127.0.0.1:8080/callback)"]}
  2. [인가 서버] ➔ [AI 에이전트]: 🔴 등록 거부 (Error)
    • 사유: "보안 정책 상 redirect_uri는 반드시 https://로 시작해야 합니다."
  3. 결과: 터미널에서 실행되는 AI 에이전트는 OAuth 로그인을 시도조차 할 수 없고 연동이 실패합니다.

✅ 바뀐 방식 흐름 (application_type: "native" 도입)

표준 RFC 8252 (OAuth 2.0 for Native Apps)를 준수하여 클라이언트의 '유형'을 명시한다.

  1. [AI 에이전트] ➔ [인가 서버]: 동적 클라이언트 등록(DCR) 요청
    • Payload: {"redirect_uris": ["[http://127.0.0.1:8080/callback](http://127.0.0.1:8080/callback)"], "application_type": "native"}
  2. [인가 서버] ➔ [AI 에이전트]: 🟢 등록 승인 (Success)
    • 판단 로직: "아, 이 클라이언트는 웹 서버가 아니라 네이티브 앱이구나. 네이티브 앱은 로컬 루프백 주소(127.0.0.1)를 사용하는 것이 표준이므로 허용해 주자."
  3. [사용자 브라우저] ➔ [인가 서버]: 로그인 및 권한 승인 진행
  4. [인가 서버] ➔ [AI 에이전트 (로컬 8080포트)]: 리다이렉트 성공
    • [http://127.0.0.1:8080/callback?code=SplxlOBeZQQYbYS6WxSbIA](http://127.0.0.1:8080/callback?code=SplxlOBeZQQYbYS6WxSbIA)
  5. 결과: AI 에이전트가 코드를 정상적으로 수신하고 엑세스 토큰으로 교환한다.

 

강화 포인트 2: 인가 서버 혼동 공격 방어 (RFC 9207 iss 검증)

클라이언트(AI 에이전트)가 여러 개의 인가 서버(예: 사내 SSO 서버, 외부 파트너사 SSO 등)와 통신할 수 있을 때 발생할 수 있는 보안 취약점(Mix-up Attack)을 원천 차단하는 스펙이다.

악의적인 공격자가 클라이언트를 속여, 클라이언트가 정상적인 서버(A)에 로그인하려고 했으나 실제로는 공격자의 서버(B)로 코드를 보내게 만드는 공격이다.

❌ 이전 방식 흐름 (취약점 존재)

리다이렉트 시 돌아오는 응답에 이 코드를 '누가' 발급했는지에 대한 정보가 없다.

  1. [AI 에이전트] ➔ [사용자 브라우저]: 정상 인가 서버(Trust-IdP)로 로그인 요청 시도. (단, 네트워크나 설정 변조로 인해 악성 서버(Evil-IdP)를 경유하게 됨)
  2. [Evil-IdP] ➔ [Trust-IdP]: 공격자가 클라이언트인 척 Trust-IdP로 요청을 토스함.
  3. [Trust-IdP] ➔ [사용자 브라우저]: 사용자 로그인 완료 후 브라우저를 통해 리다이렉트 시킴.
    • [http://127.0.0.1:8080/callback?code=AuthCode_1234](http://127.0.0.1:8080/callback?code=AuthCode_1234)
  4. [AI 에이전트]: 코드를 수신함. 하지만 이 코드가 Trust-IdP에서 온 것인지, Evil-IdP에서 온 것인지 구분할 단서가 없음.
  5. [AI 에이전트] ➔ [Evil-IdP]: 에이전트는 설정 변조된 대로 악성 서버(Evil-IdP)의 토큰 엔드포인트로 code=AuthCode_1234를 전송함.
  6. 결과: 🔴 공격자(Evil-IdP)가 정상적인 인가 코드를 탈취하여 토큰을 훔쳐냄.

✅ 바뀐 방식 흐름 (RFC 9207 적용)

인가 서버는 리다이렉트 시 반드시 iss (Issuer, 발급자 식별자) 파라미터를 포함해야 하고, 클라이언트는 이를 엄격히 검증한다.

  1. [AI 에이전트] ➔ [사용자 브라우저]: 정상 인가 서버(Trust-IdP)로 로그인 요청 시도. (공격 개입)
  2. [Trust-IdP] ➔ [사용자 브라우저]: 사용자 로그인 완료 후 리다이렉트 시킴. (이때 발급자 정보 iss를 반드시 포함함)
    • [http://127.0.0.1:8080/callback?code=AuthCode_1234](http://127.0.0.1:8080/callback?code=AuthCode_1234)&iss=[https://trust-idp.example.com](https://trust-idp.example.com)
  3. [AI 에이전트]: 코드와 함께 iss 값을 수신함.
  4. [AI 에이전트의 내부 검증 로직 가동]:
    • "내가 애초에 이 로그인을 시작할 때 설정했던 인가 서버 주소가 어디였지?" ➔ [https://evil-idp.com](https://evil-idp.com) (공격자에 의해 변조된 주소)
    • "지금 코드를 발급했다고 주장하는 곳(iss)은 어디지?" ➔ [https://trust-idp.example.com](https://trust-idp.example.com)
    • "두 주소가 다르네? 누군가 중간에 장난을 쳤구나!"
  5. 결과: 🟢 에이전트는 토큰 교환을 즉시 중단(Abort)하고 인가 코드를 폐기하여 탈취를 방지한다.


부가 변경 사항: DCR 퇴출과 CIMD 도입

이러한 OAuth 연동을 위해 클라이언트를 서버에 등록하는 과정 자체도 바뀐다. 기존의 동적 클라이언트 등록(DCR)은 클라이언트가 실행될 때마다 인가 서버에 자신을 등록하는 API를 호출해야 했다.

새로운 스펙에서는 CIMD (Client ID Metadata Documents)를 표준으로 삼는다. 이는 쿠버네티스에서 CRD나 ConfigMap으로 상태를 선언(Declarative)해두는 것과 유사한 접근이다. 클라이언트의 메타데이터(퍼블릭 키, 리다이렉트 URI 등)를 정적인 JSON 문서 형태로 호스팅하고, 인가 서버는 해당 문서를 읽어 신뢰 관계를 구축한다. 이는 인가 서버 측의 상태 관리 부담을 줄이고 인프라적 안정성을 높이는 방향이다.

이번 변경 사항인 DCR(동적 클라이언트 등록) 퇴출과 CIMD(클라이언트 ID 메타데이터 문서) 도입은, 인가 서버(Authorization Server)마저도 상태 비저장(Stateless)과 선언적(Declarative) 아키텍처로 전환하려는 클라우드 네이티브의 철학이 깊게 반영된 조치이다.

특히 쿠버네티스 환경의 GitOps(선언적 상태 관리) 익숙하시다면, 이 변화가 '명령형(Imperative) API 호출을 통한 상태 생성'에서 '선언형(Declarative) 문서 호스팅을 통한 상태 증명'으로 넘어가는 과정이라는 점을 쉽게 체감하실 수 있을 것이다.

이전의 DCR 방식과 새로운 CIMD 방식을 상세히 비교했다.

❌ 이전 방식: DCR (Dynamic Client Registration)

DCR은 클라이언트(AI 에이전트)가 실행될 때마다 인가 서버의 /register API를 호출하여 자신을 동적으로 등록하고, 인가 서버로부터 고유한 client_id를 발급받는 방식이다.

1. DCR 순서 흐름 (Stateful & Imperative)

  1. [AI 에이전트] ➔ [인가 서버]: 최초 실행 시 등록 API 호출
    • POST /register
    • Body: {"client_name": "My Agent", "redirect_uris": ["http://localhost:8080/cb"]}
  2. [인가 서버 내부 로직]: 전달받은 정보를 자신의 데이터베이스(RDBMS 등)에 저장(Stateful)하고, 새 client_id와 client_secret을 생성.
  3. [인가 서버] ➔ [AI 에이전트]: 발급된 자격 증명 반환
    • Body: {"client_id": "auto-gen-uuid-1234", "client_secret": "..."}
  4. [AI 에이전트] ➔ [인가 서버]: 사용자 로그인을 위해 인가 요청 시작
    • GET /authorize?client_id=auto-gen-uuid-1234&...
  5. [인가 서버 내부 로직]: DB에서 auto-gen-uuid-1234를 조회하여 등록된 redirect_uri가 맞는지 검증 후 로그인 진행.

🚨 DCR의 문제점 (엔터프라이즈 환경)

  • 상태 관리의 부담: 수많은 에이전트 파드(Pod)가 뜰 때마다 등록을 요청하면 인가 서버의 DB에 쓰레기 데이터(Orphaned Clients)가 무한정 쌓인다.
  • 보안 취약점: 아무나 /register 엔드포인트를 찔러 클라이언트를 무단으로 등록할 수 있어, 엔터프라이즈 환경에서는 보통 이 기능을 닫아두거나 관리에 골머리를 앓았다.

✅ 바뀐 방식: CIMD (Client ID Metadata Document)

CIMD 체계에서는 client_id 자체가 하나의 URL 주소가 된다. 클라이언트는 인가 서버에 자신을 '등록'해 달라고 부탁하는 대신, 자신의 정보(리다이렉트 주소, 공개 키 등)가 담긴 정적인 JSON 문서를 특정 URL에 퍼블리싱(호스팅)해 둔다.

1. CIMD 순서 흐름 (Stateless & Declarative)

  1. [사전 준비 (GitOps/배포 단계)]: 개발자나 파이프라인이 AI 에이전트의 메타데이터 JSON 파일을 사내 웹 서버(또는 S3 등)에 정적으로 배포한다.
    • URL: [https://my-agent.ktcloud.com/client.json](https://my-agent.ktcloud.com/client.json)
  2. [AI 에이전트] ➔ [인가 서버]: 에이전트가 인가 서버에 등록하는 과정을 생략하고, 즉시 로그인(인가) 요청을 시작한다. 이때 client_id로 자신의 문서 URL을 보낸다.
    • GET /authorize?client_id=[https://my-agent.ktcloud.com/client.json](https://my-agent.ktcloud.com/client.json)&...
  3. [인가 서버] ➔ [해당 URL]: 인가 서버는 전달받은 client_id가 URL 형태인 것을 보고, 해당 주소로 메타데이터 문서를 Fetch(조회) 한다.
    • GET [https://my-agent.ktcloud.com/client.json](https://my-agent.ktcloud.com/client.json)
  4. [인가 서버 내부 로직]: 받아온 정적 JSON 문서의 내용(리다이렉트 주소, 암호화 퍼블릭 키 등)과 현재 요청을 대조하여 검증한다. (DB 조회 불필요)
  5. [인가 서버] ➔ [사용자 브라우저]: 검증 성공 시 사용자 로그인 화면 노출 및 이후 OAuth 흐름 정상 진행.

 

1) 무엇과 무엇을 대조하는가?

인가 서버는 현재 들어온 API 요청이 유효한지 확인하기 위해, 요청 파라미터를 JSON 문서(클라이언트의 메타데이터나 JWKS 등)의 내용과 대조한다.

  • 리다이렉트 주소(Redirect URI) 검증:
    • 요청 값: 클라이언트가 인가 코드(Auth Code)를 받기 위해 파라미터로 보낸 redirect_uri (예: [https://app.example.com/callback](https://app.example.com/callback))
    • JSON 값: 정적 JSON 문서에 명시된 허용 가능한 redirect_uris 배열.
    • 대조 로직: 요청받은 주소가 JSON 문서의 배열 안에 존재하는지 확인합니다. 일치하지 않으면 인가 서버는 탈취된 주소로 간주하고 에러를 반환한다.
  • 암호화 퍼블릭 키(Public Key) 검증:
    • 요청 값: 클라이언트가 보낸 서명된 요청(Signed Request Object) 또는 클라이언트 인증용 JWT.
    • JSON 값: 정적 JSON 문서(보통 JWKS 형태)에 포함된 퍼블릭 키(Public Key).
    • 대조 로직: JSON 문서에서 추출한 퍼블릭 키를 사용하여 클라이언트가 보낸 서명의 유효성을 수학적으로 검증합니다. 서명이 일치하면 위변조되지 않은 요청임이 증명된다.

2) 왜 인가 서버의 DB 조회가 필요 없는가?

인가 서버의 DB를 조회해야 한다는 것은 상태 유지(Stateful)를 전제로 하는 전통적인 방식이다. 클라이언트를 사전에 등록하고 그 정보를 DB에 저장해 두어야 하기 때문이다.

하지만 설명에 나온 아키텍처에서는 정적 JSON 문서 자체가 DB를 대체하는 '신뢰의 원천(Source of Truth)' 역할을 한다.

  1. 신뢰 체인(Trust Chain)의 이동: 인가 서버는 사전에 약속된 안전한 URL(예: /.well-known/openid-configuration 또는 클라이언트의 신뢰할 수 있는 호스트)에서 JSON 문서를 가져온다. HTTPS/TLS 등을 통해 이 문서를 안전하게 가져왔거나 문서 자체가 서명되어 있다면, 인가 서버는 그 문서 안의 데이터를 100% 신뢰할 수 있다.
  2. DB 부하 감소 및 유연성 확보: 클라이언트의 설정이 바뀔 때마다 인가 서버의 DB를 업데이트할 필요가 없다. 클라이언트 측에서 자신의 정적 JSON 문서만 업데이트하면, 인가 서버는 요청이 들어올 때마다 최신 JSON을 읽어와(또는 캐싱하여) 즉시 검증할 수 있다.
  3. 무상태(Stateless) 검증: 중앙 DB에 접근하기 위한 네트워크 비용이나 트랜잭션을 일으키지 않고, 메모리 상에서 가져온 JSON 객체와 Request 객체만 비교하면 되므로 처리 속도가 빠르고 병목 현상이 줄어든다.

 

유즈케이스 예제: CIMD HTTP/JSON 예제

1) 호스팅된 클라이언트 메타데이터 문서 (CIMD JSON) 웹 서버가 정적으로 서빙하는 [https://my-agent.ktcloud.com/client.json](https://my-agent.ktcloud.com/client.json) 파일의 내용이다. 에이전트의 스펙이 선언적으로 명시되어 있다.

{
  "client_name": "kt cloud Internal Enterprise Agent",
  "client_uri": "https://my-agent.ktcloud.com",
  "redirect_uris": [
    "http://127.0.0.1:8080/callback"
  ],
  "application_type": "native",
  "token_endpoint_auth_method": "private_key_jwt",
  "jwks_uri": "https://my-agent.ktcloud.com/jwks.json" 
}

 

2) AI 에이전트의 최초 인가(로그인) HTTP 요청 에이전트는 인가 서버에 동적 등록을 할 필요 없이 바로 브라우저를 띄워 아래 주소로 보낸다.

GET /oauth2/authorize?
  response_type=code
  &client_id=https%3A%2F%2Fmy-agent.ktcloud.com%2Fclient.json  <-- URL 자체가 클라이언트 ID
  &redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fcallback
  &scope=mcp_tools
  &state=xyz123 HTTP/1.1
Host: auth.ktcloud.com

 

 

6. Tasks(백그라운드 작업) 정식 확장 편입

오래 걸리는 작업(Long-running tasks)을 처리하는 방식이 정비되었다.

  • 이전 방식: 코어 기능에서 실험적으로 제공되었고, 알림을 받으려면 각기 다른 HTTP GET 엔드포인트를 사용해야 했다.
  • 바뀐 방식: io.modelcontextprotocol/tasks라는 정식 확장(Extension) 프레임워크로 분리되었습니다. 폴링 방식의 tasks/get과 상태 업데이트를 위한 tasks/update가 도입되었으며, 모든 변경 알림은 단일 subscriptions/listen 스트림으로 통합되었다.

 

유즈케이스: 10GB 대용량 로그 데이터 분석 (소요 시간 10분)

AI 에이전트(클라이언트)가 서버에게 "최근 한 달 치 10GB 웹 서버 로그에서 에러 패턴을 분석해 줘"라고 요청한다. 10분이 걸리는 작업이므로 기존 동기식(Synchronous) HTTP 요청이었다면 무조건 타임아웃(Timeout)이 발생했을 것이다.

에이전트가 "10GB 로그 분석"을 요청하면 즉각적인 결과 대신 Task ID를 받는다. 이후 단일 구독 스트림(subscriptions/listen)을 통해 진행도(30% -> 70% -> 완료) 알림을 효율적으로 수신할 수 있다.

이를 Tasks 확장과 subscriptions/listen을 활용해 비동기식(Asynchronous)으로 처리하는 흐름이다.

1. 순서도 (Sequence Flow)

  1. [구독 연결] 에이전트 ➔ 서버: 먼저 subscriptions/listen 스트림(Streamable HTTP)을 열어 알림 채널을 유지한다.
  2. [작업 지시] 에이전트 ➔ 서버: 도구(Tool)를 호출하여 데이터 분석을 지시한다.
  3. [즉시 응답] 서버 ➔ 에이전트: 작업을 큐(Queue)에 넣고, 즉시 Task ID를 반환한다. (HTTP 연결 종료)
  4. [백그라운드 실행] 서버: 백그라운드 워커가 10GB 로그 분석을 시작한다.
  5. [상태 변경 알림] 서버 ➔ 에이전트: 분석이 50% 진행되었을 때, 1번에서 열어둔 스트림으로 "상태 변경됨" 알림을 푸시(Push)한다.
  6. [상세 조회] 에이전트 ➔ 서버: 알림을 받은 에이전트는 tasks/get을 호출해 진행도를 확인한다. (응답: "50% 진행 중")
  7. [완료 알림] 서버 ➔ 에이전트: 작업이 100% 완료되면 스트림으로 다시 완료 알림을 푸시(Push)한다.
  8. [결과 조회] 에이전트 ➔ 서버: 에이전트가 마지막으로 tasks/get을 호출하여 최종 분석 리포트를 받아온다.
  • 에이전트가 "10GB 로그 분석"을 요청하면 즉각적인 결과 대신 Task ID를 받는다. 이후 단일 구독 스트림(subscriptions/listen)을 통해 진행도(30% -> 70% -> 완료) 알림을 효율적으로 수신할 수 있다.

 

2. HTTP / JSON-RPC 호출 예제 시나리오

Step 1: 스트림 구독 (Client ➔ Server) 클라이언트가 먼저 서버의 알림을 받기 위해 단일 스트림 채널을 연다. 이 연결은 계속 유지된.

POST /mcp/subscriptions/listen HTTP/1.1
Host: api.ktcloud.com
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "subscriptions/listen",
  "params": {
    "topics": ["io.modelcontextprotocol/tasks"] // Tasks 관련 알림만 구독하겠다고 선언
  }
}

 

Step 2: 작업 지시 (Client ➔ Server) 분석 툴을 호출한다. (이 요청은 일반적인 HTTP 요청이므로 금방 끊어진다.)

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "analyze_large_logs",
    "arguments": { "target_size": "10GB" }
  }
}

Step 3: 즉시 응답 (Server ➔ Client) 서버는 데이터를 처리하기 전에 예약표(taskId)만 먼저 던져준다.

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "status": "accepted",
    "taskId": "task-log-9991"
  }
}

Step 4 & 5: 백그라운드 진행 및 푸시 알림 (Server ➔ Client) 진행 상태가 변하면, 아까 열어둔 subscriptions/listen 스트림을 통해 서버가 이벤트를 쏩니다.

// (스트림을 통해 서버가 일방적으로 내려보내는 데이터)
{
  "jsonrpc": "2.0",
  "method": "notifications/tasks/updated",
  "params": {
    "taskId": "task-log-9991"
  }
}

💡 주의: 알림에는 "50% 진행됨" 같은 무거운 데이터가 들어있지 않습니다. 오직 "이 Task에 변화가 생겼다"는 신호만 간다.

Step 6: 상세 조회 (Client ➔ Server) 알림을 받은 에이전트가 "뭐가 변했지?" 하고 단발성 API(tasks/get)로 찔러본다. (Polling)

// 요청
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tasks/get",
  "params": { "taskId": "task-log-9991" }
}

// 응답
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "taskId": "task-log-9991",
    "status": "running",
    "progress": 50,
    "message": "파싱 중 (5GB/10GB)"
  }
}

Step 7 & 8: 완료 알림 및 최종 결과 수령 작업이 끝나면 다시 스트림으로 알림이 오고, 클라이언트가 다시 tasks/get을 호출하면 최종 결과를 준다.

// 최종 응답
{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "taskId": "task-log-9991",
    "status": "completed",
    "result_data": {
      "error_count": 402,
      "top_error": "ConnectionTimeout",
      "report_url": "https://api.ktcloud.com/reports/log-9991.pdf"
    }
  }
}

 

왜 이렇게 설계했을까요?

만약 스트림(Push)에 10GB 분석 결과를 통째로 담아서 보낸다면 스트림 트래픽이 폭주하고 네트워크가 불안정해질 수 있다. 반대로 스트림 없이 클라이언트가 1초마다 무지성으로 "다 됐어?" 하고 물어보는 방식(순수 Polling)은 서버에 엄청난 HTTP 부하(DDos와 유사)를 일으킨다.

새로운 MCP의 subscriptions/listen + tasks/get 방식은 가벼운 신호(Signal)는 스트림으로 Push하고, 무거운 데이터(Payload)는 필요할 때만 HTTP로 Fetch(가져오기) 하는 클라우드 네이티브의 이벤트 주도(Event-driven) 아키텍처 베스트 프랙티스를 그대로 프로토콜 스펙으로 가져온 것이다.

 

Node.js (TypeScript/Express)로 구현한 슈도 코드

import express from 'express';

const app = express();
app.use(express.json());

// 구독 중인 클라이언트들의 HTTP 응답(Response) 스트림 객체를 담아둘 Map
// 세션 ID(Mcp-Session-Id)가 아니라, 순수하게 현재 열려있는 TCP 연결 객체를 관리합니다.
const activeStreams = new Set<express.Response>();

/**
 * 1. 단일 MCP 엔드포인트 및 헤더 기반 라우팅
 */
app.post('/mcp', (req, res) => {
    // 바뀐 SPEC: JSON 바디를 열어보지 않고 헤더만으로 라우팅 (API 게이트웨이 친화적)
    const mcpMethod = req.headers['mcp-method'];
    const mcpName = req.headers['mcp-name'];

    // --- A. 구독(스트림) 연결 요청 처리 ---
    if (mcpMethod === 'subscriptions/listen') {
        // SSE(text/event-stream)가 아닌 일반 NDJSON 스트림 설정
        res.setHeader('Content-Type', 'application/x-ndjson'); 
        res.setHeader('Transfer-Encoding', 'chunked'); // HTTP/1.1 청크 스트리밍 핵심
        res.setHeader('Connection', 'keep-alive');

        // 연결된 응답 객체를 Set에 저장 (알림을 쏠 타겟)
        activeStreams.add(res);

        // 클라이언트가 네트워크를 끊으면 Set에서 제거하여 메모리 누수 방지
        req.on('close', () => {
            activeStreams.delete(res);
        });

        // 구독 성공 즉시 응답 (스트림을 닫지 않기 위해 res.end()를 호출하지 않음)
        const successResponse = {
            jsonrpc: "2.0",
            id: req.body.id,
            result: { status: "subscribed" }
        };
        // JSON 객체를 String으로 변환 후 개행문자(\n)를 붙여서 전송
        res.write(JSON.stringify(successResponse) + '\n');
        return; // 스트림 열어둔 채로 대기
    }

    // --- B. 일반 도구 호출 처리 ---
    if (mcpMethod === 'tools/call' && mcpName === 'analyze_large_logs') {
        const taskId = `task-${Date.now()}`;
        
        // 백그라운드 워커에 작업 위임 (비동기)
        startBackgroundWorker(taskId, req.body.params.arguments);

        // 클라이언트에게는 HTTP 즉시 종료 (200 OK) 및 Task ID 반환
        res.json({
            jsonrpc: "2.0",
            id: req.body.id,
            result: { status: "accepted", taskId: taskId }
        });
        return;
    }

    res.status(404).send('Not Found');
});

/**
 * 2. 백그라운드 작업 상태 변경 시 클라이언트(들)에게 알림 푸시
 */
function notifyTaskUpdate(taskId: string) {
    // 서버가 클라이언트에게 쏘는 순수 JSON-RPC Notification 형식 (응답 ID가 없음)
    const notification = {
        jsonrpc: "2.0",
        method: "notifications/tasks/updated",
        params: {
            taskId: taskId
        }
    };

    const payload = JSON.stringify(notification) + '\n';

    // 현재 열려있는 모든 스트림에 데이터를 밀어 넣음 (Write)
    // (실제 프로덕션 환경에서는 해당 Task를 요청한 특정 사용자/채널의 스트림으로 필터링하는 로직이 추가됩니다)
    for (const streamRes of activeStreams) {
        // SSE의 event: / data: 래핑 없이 순수 JSON 텍스트만 전송
        streamRes.write(payload); 
    }
}

// 가상의 백그라운드 워커 시뮬레이션
function startBackgroundWorker(taskId: string, args: any) {
    setTimeout(() => {
        // 5초 뒤 진행 상태 변경 알림 발생
        notifyTaskUpdate(taskId);
    }, 5000);
}

app.listen(3000, () => console.log('MCP Stateless Server running on port 3000'));

 

 

 

반응형
Posted by seungkyua@gmail.com
,
반응형

2024년 행사 Recap

반응형
Posted by seungkyua@gmail.com
,
반응형
반응형
Posted by seungkyua@gmail.com
,
반응형

1. sudo 사용자 추가, 보안 s/w 내리기, swap off

$ sudo adduser ask

$ cat <<EOF | sudo tee /etc/sudoers.d/sudoers-ask
ask     ALL=(ALL:ALL)   NOPASSWD:ALL
EOF

$ sudo systemctl stop ufw
$ sudo systemctl disable ufw
$ sudo systemctl stop apparmor.service
$ sudo systemctl disable apparmor.service
$ sudo swapoff -a

 

2. module load

$ cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF

$ sudo modprobe overlay
$ sudo modprobe br_netfilter

 

3. network forwarding 설정

$ cat <<EOF | sudo tee /etc/sysctl.d/99-kubernetes.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF


$ sudo sysctl --system
$ sudo iptables -P FORWARD ACCEPT

 

4. containerd 설치

$ sudo apt-get update
$ sudo apt-get install -y apt-transport-https ca-certificates curl gpg
$ sudo install -m 0755 -d /etc/apt/keyrings
$ sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
$ sudo chmod a+r /etc/apt/keyrings/docker.asc

$ echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

$ sudo apt-get update
$ sudo apt-get install -y containerd.io

$ sudo mkdir -p /etc/containerd
$ sudo containerd config default | sudo tee /etc/containerd/config.toml

$ sudo sed -i 's/SystemdCgroup \= false/SystemdCgroup \= true/g' /etc/containerd/config.toml

$ sudo systemctl restart containerd
$ sudo systemctl enable containerd
$ sudo systemctl status containerd

 

5. crictl 설치

#----------------------------------------------------------
# 서버가 arm64 or amd64 인지 확인하여 설치
#----------------------------------------------------------
$ VERSION="v1.30.0"
$ curl -L https://github.com/kubernetes-sigs/cri-tools/releases/download/$VERSION/crictl-${VERSION}-linux-arm64.tar.gz --output crictl-${VERSION}-linux-arm64.tar.gz

$ sudo tar zxvf crictl-$VERSION-linux-arm64.tar.gz -C /usr/local/bin
$ rm -f crictl-$VERSION-linux-arm64.tar.gz

#----------------------------------------------------------
# crictl 이 어느 container 를 접속할 것인지 세팅
#----------------------------------------------------------
$ cat <<EOF | sudo tee /etc/crictl.yaml
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 2
debug: false
pull-image-on-create: false
EOF

$ sudo bash -c "crictl completion > /etc/bash_completion.d/crictl"
$ source ~/.bashrc


#----------------------------------------------------------
# containerd 설정 확인
#----------------------------------------------------------
$ sudo crictl info

 

6. kubectl 설치

#----------------------------------------------------------
# 서버가 arm64 or amd64 인지 확인하여 설치
#----------------------------------------------------------
$ curl -LO "https://dl.k8s.io/release/v1.30.0/bin/linux/arm64/kubectl"
$ chmod +x ./kubectl
$ sudo mv ./kubectl /usr/local/bin/kubectl

 

7.  Kubernetes 설치

$ mkdir -p ~/kubeadm && cd ~/kubeadm

#-----------------------------------------------
# kubernetes 다운로드 key 와 url 등록
#-----------------------------------------------
$ curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.30/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg

$ echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
$ sudo apt-get update


#========================================================================
# kubelet 설치 (아직 kubelet 이 뜨지는 않음)
#========================================================================
$ sudo apt-get install -y kubelet="1.30.3-*" kubeadm="1.30.3-*"
$ sudo systemctl enable --now kubelet
$ sudo systemctl start kubelet

#========================================================================
# 1. kubeadm 설치
#========================================================================
$ sudo kubeadm config images pull

#------------------------------------------------------------
# 자기 노드의 ip: --apiserver-advertise-address
# multi control-plane 일 경우 L4 ip: --control-plane-endpoint
# cgroup driver 세팅 (https://kubernetes.io/docs/tasks/administer-cluster/kubeadm/configure-cgroup-driver/)
#------------------------------------------------------------
$ vi kubeadm-config.yaml

apiVersion: kubeadm.k8s.io/v1beta3
kind: InitConfiguration
nodeRegistration:
  criSocket: "/var/run/containerd/containerd.sock"

---
apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
apiServer:
  certSANs:
  - 127.0.0.1
  - localhost
  - <Node private IP>
  - <Node public IP>
networking:
  serviceSubnet: 10.233.0.0/18
  podSubnet: 10.233.64.0/18
  dnsDomain: "cluster.local"


#----------------------------------------------------------------
# kubeadm init 을 하고 나면 /var/lib/kubelet/config.yaml 이 생성되어
# kubelet 이 정상적으로 실행됨
#----------------------------------------------------------------
$ sudo kubeadm init --config kubeadm-config.yaml --v=5


#------------------------------------------------------------
# kubeconfig
#------------------------------------------------------------
$ mkdir -p ~/.kube
$ sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
$ sudo chown $(id -u):$(id -g) $HOME/.kube/config

 

8. Calico 설치

$ mkdir -p ~/calico && cd ~/calico

$ curl -LO https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/tigera-operator.yaml

$ kubectl create -f tigera-operator.yaml

$ curl -LO https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/custom-resources.yaml

#------------------------------------------------------------
# yaml 을 열어서 pod 네트워크를 확인하고 변경
#------------------------------------------------------------
$ vi custom-resources.yaml
...
    cidr: 10.233.64.0/18
...

$ kubectl create -f custom-resources.yaml

#------------------------------------------------------------
# calico 설치 확인
#------------------------------------------------------------
$ kubectl get pods -n calico-system

 

9. kubectl bash completion

$ source <(kubectl completion bash)
$ kubectl completion bash > ~/.kube/completion.bash.inc

$ printf "
# kubectl shell completion
source '$HOME/.kube/completion.bash.inc'
" >> $HOME/.bash_aliases

$ source $HOME/.bash_aliases

 

10. taint 제거

$ kubectl taint nodes --all node-role.kubernetes.io/control-plane-

 

11. 설치 테스트

$ mkdir -p ~/sample-yaml && cd ~/sample-yaml

$ cat <<EOF | tee ./nginx-service.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: nginx-deployment
  name: nginx-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.21.0
        ports:
        - containerPort: 80

---
apiVersion: v1
kind: Service
metadata:
  name: nginx-service-nodeport
spec:
  selector:
    app: nginx
  ports:
  - protocol: TCP
    port: 8080
    targetPort: 80
    nodePort: 30180
  type: NodePort
  externalTrafficPolicy: Local
EOF


$ kubectl apply -f nginx-service.yaml

$ kubectl get pods

$ curl <Node ip>:30180
반응형
Posted by seungkyua@gmail.com
,
반응형

Minikube 설치하기

minkube 설치는 아래 사이트를 참고한다.

https://minikube.sigs.k8s.io/docs/start/

$ curl -LO <https://storage.googleapis.com/minikube/releases/latest/minikube-darwin-arm64>
$ install minikube-darwin-arm64 /Users/ask/bin/minikube

start cluster

https://minikube.sigs.k8s.io/docs/drivers/docker/

driver 를 docker 로 사용하기 때문에 docker 를 미리 설치해 놓아야 한다.

$ docker context use default

이후에 minikube 로 cluster 를 생성한다.

$ minikube start --driver=docker --memory=4096
--- output ---
😄  Darwin 13.5.2 (arm64) 의 minikube v1.33.0
✨  유저 환경 설정 정보에 기반하여 docker 드라이버를 사용하는 중
📌  Using Docker Desktop driver with root privileges
👍  Starting "minikube" primary control-plane node in "minikube" cluster
🚜  Pulling base image v0.0.43 ...
🔥  Creating docker container (CPUs=2, Memory=4096MB) ...
❗  This container is having trouble accessing <https://registry.k8s.io>
💡  To pull new external images, you may need to configure a proxy: <https://minikube.sigs.k8s.io/docs/reference/networking/proxy/>
🐳  쿠버네티스 v1.30.0 을 Docker 26.0.1 런타임으로 설치하는 중
    ▪ 인증서 및 키를 생성하는 중 ...
    ▪ 컨트롤 플레인을 부팅하는 중 ...
    ▪ RBAC 규칙을 구성하는 중 ...
🔗  bridge CNI (Container Networking Interface) 를 구성하는 중 ...
🔎  Kubernetes 구성 요소를 확인...
    ▪ Using image gcr.io/k8s-minikube/storage-provisioner:v5
🌟  애드온 활성화 : storage-provisioner, default-storageclass

❗  /Users/ask/bin/kubectl is version 1.28.2, which may have incompatibilities with Kubernetes 1.30.0.
    ▪ Want kubectl v1.30.0? Try 'minikube kubectl -- get pods -A'
🏄  끝났습니다! kubectl이 "minikube" 클러스터와 "default" 네임스페이스를 기본적으로 사용하도록 구성되었습니다.

디폴트 메모리를 config 에 세팅할 수 도 있다.

config 세팅은 기존에 만든 minikube node 에는 적용이 안되고 새롭게 만드는 노드에만 적용된다.

$ minikube config set memory 4096

node 추가

minikube 에 worker node 를 추가할 수 있다.

$ minikube node add

node 가 추가된 것을 볼 수 있다.

$ kubectl get nodes
NAME           STATUS   ROLES           AGE     VERSION
minikube       Ready    control-plane   8m22s   v1.30.0
minikube-m02   Ready    <none>          86s     v1.30.0

node 에 Role 을 추가하고 싶으면 다음과 같이 지정한다.

$ kubectl label node minikube-m02 node-role.kubernetes.io/node=enabled --overwrite

$ kubectl get nodes
NAME           STATUS   ROLES           AGE     VERSION
minikube       Ready    control-plane   15m     v1.30.0
minikube-m02   Ready    node            8m12s   v1.30.0

minikube 명령어

  • 현재 설치된 minkube node 를 조회한다.
$ minkube status
--- output ---
minikube
type: Control Plane
host: Running
kubelet: Running
apiserver: Running
kubeconfig: Configured

minikube-m02
type: Worker
host: Running
kubelet: Running
  • minikube 로 만든 kubernetes cluster 의 모든 namespace 를 잠시 멈춘다.
$ minkube pause -A
--- output --
⏸️  Pausing node minikube ...
⏸️  Pausing node m02 ...
⏯️  Paused 16 containers
  • minkube 로 pause 한 cluster 를 다시 실행한다.
$ minikube unpause -A
--- output ---
⏸️  Unpausing node minikube ...
⏸️  Unpausing node m02 ...
⏸️  Unpaused 16 containers
  • minikube 모든 노드를 중지한다.
$ minikube stop --all=true
--- output ---
✋  "minikube-m02" 노드를 중지하는 중 ...
🛑  "minikube-m02"를 SSH로 전원을 끕니다 ...
✋  "minikube" 노드를 중지하는 중 ...
🛑  "minikube"를 SSH로 전원을 끕니다 ...
🛑  2개의 노드가 중지되었습니다.
  • aged 란 이름의 다른 쿠버네티스 버전을 추가로 설치한다.
$ minikube start -p aged --kubernetes-version=v1.28.2
  • 모든 minikube 로 만든 kubernetes cluster 를 삭제한다.
minikube delete --all
  • minikube addons 목록을 살펴본다.
$ minikube addons list
|-----------------------------|----------|--------------|--------------------------------|
|         ADDON NAME          | PROFILE  |    STATUS    |           MAINTAINER           |
|-----------------------------|----------|--------------|--------------------------------|
| ambassador                  | minikube | disabled     | 3rd party (Ambassador)         |
| auto-pause                  | minikube | disabled     | minikube                       |
| cloud-spanner               | minikube | disabled     | Google                         |
| csi-hostpath-driver         | minikube | disabled     | Kubernetes                     |
| dashboard                   | minikube | disabled     | Kubernetes                     |
| default-storageclass        | minikube | enabled ✅   | Kubernetes                     |
| efk                         | minikube | disabled     | 3rd party (Elastic)            |
| freshpod                    | minikube | disabled     | Google                         |
| gcp-auth                    | minikube | disabled     | Google                         |
| gvisor                      | minikube | disabled     | minikube                       |
| headlamp                    | minikube | disabled     | 3rd party (kinvolk.io)         |
| helm-tiller                 | minikube | disabled     | 3rd party (Helm)               |
| inaccel                     | minikube | disabled     | 3rd party (InAccel             |
|                             |          |              | [info@inaccel.com])            |
| ingress                     | minikube | disabled     | Kubernetes                     |
| ingress-dns                 | minikube | disabled     | minikube                       |
| inspektor-gadget            | minikube | disabled     | 3rd party                      |
|                             |          |              | (inspektor-gadget.io)          |
| istio                       | minikube | disabled     | 3rd party (Istio)              |
| istio-provisioner           | minikube | disabled     | 3rd party (Istio)              |
| kong                        | minikube | disabled     | 3rd party (Kong HQ)            |
| kubeflow                    | minikube | disabled     | 3rd party                      |
| kubevirt                    | minikube | disabled     | 3rd party (KubeVirt)           |
| logviewer                   | minikube | disabled     | 3rd party (unknown)            |
| metallb                     | minikube | disabled     | 3rd party (MetalLB)            |
| metrics-server              | minikube | disabled     | Kubernetes                     |
| nvidia-device-plugin        | minikube | disabled     | 3rd party (NVIDIA)             |
| nvidia-driver-installer     | minikube | disabled     | 3rd party (Nvidia)             |
| nvidia-gpu-device-plugin    | minikube | disabled     | 3rd party (Nvidia)             |
| olm                         | minikube | disabled     | 3rd party (Operator Framework) |
| pod-security-policy         | minikube | disabled     | 3rd party (unknown)            |
| portainer                   | minikube | disabled     | 3rd party (Portainer.io)       |
| registry                    | minikube | disabled     | minikube                       |
| registry-aliases            | minikube | disabled     | 3rd party (unknown)            |
| registry-creds              | minikube | disabled     | 3rd party (UPMC Enterprises)   |
| storage-provisioner         | minikube | enabled ✅   | minikube                       |
| storage-provisioner-gluster | minikube | disabled     | 3rd party (Gluster)            |
| storage-provisioner-rancher | minikube | disabled     | 3rd party (Rancher)            |
| volumesnapshots             | minikube | disabled     | Kubernetes                     |
| yakd                        | minikube | disabled     | 3rd party (marcnuri.com)       |
|-----------------------------|----------|--------------|--------------------------------|
  • dashboard 를 설치한다.
$ minikube addons enable dashboard
--- output ---
💡  dashboard is an addon maintained by Kubernetes. For any concerns contact minikube on GitHub.
You can view the list of minikube maintainers at: <https://github.com/kubernetes/minikube/blob/master/OWNERS>
    ▪ Using image docker.io/kubernetesui/dashboard:v2.7.0
    ▪ Using image docker.io/kubernetesui/metrics-scraper:v1.0.8
💡  Some dashboard features require the metrics-server addon. To enable all features please run:

	minikube addons enable metrics-server

🌟  'dashboard' 애드온이 활성화되었습니다
  • metrics-server 를 설치한다.
$ minikube addons enable metrics-server
--- output ---
💡  metrics-server is an addon maintained by Kubernetes. For any concerns contact minikube on GitHub.
You can view the list of minikube maintainers at: <https://github.com/kubernetes/minikube/blob/master/OWNERS>
    ▪ Using image registry.k8s.io/metrics-server/metrics-server:v0.7.1
🌟  'metrics-server' 애드온이 활성화되었습니다
  • dashboard 에 접속한다.
$ minikube dashboard --url

minikube trouble shooting

minikube 를 설치하고 pod 가 생성안되는 가장 큰 이유는 proxy 때문이다. 회사에서 proxy 를 사용한다면 proxy 세팅을 추가로 해줘야 한다.

아래 내용은 proxy 이외의 문제일 때 해결 방법이다. 

  • docker hub 으로 부터 이미지를 다운 받지 못하는 문제
Error response from daemon: Get "<https://registry-1.docker.io/v2/>": tls: failed to verify certificate: x509: certificate signed by unknown authority

minikube 의 docker-env 를 확인한 후 세팅한다.

$ minikube -p minikube docker-env
--- output ---
export DOCKER_TLS_VERIFY="1"
export DOCKER_HOST="tcp://127.0.0.1:53131"
export DOCKER_CERT_PATH="/Users/ask/.minikube/certs"
export MINIKUBE_ACTIVE_DOCKERD="minikube"

$ docker context create minikube --description "Minikube" --docker "host=tcp://localhost:53131,ca=/Users/ask/.minikube/certs/ca.pem,cert=/Users/ask/.minikube/certs/cert.pem,key=/Users/ask/.minikube/certs/key.pem"
$ docker context use minikube

위의 경우에는 minikube 를 띄울 때 --insecure-registry 를 추가한다.

$ minikube start --insecure-registry="registry-1.docker.io"

아니면 minikube 로 노드에 접속해서 docker 에 인증서를 추가한다.

$ minikube ssh
$ sudo su -

$ update-ca-certificates --fresh
$ openssl s_client -showcerts -verify 5 -connect k8s.gcr.io:443 < /dev/null 2>/dev/null | openssl x509 -outform PEM | tee ~/k8s.gcr.io.crt
$ openssl s_client -showcerts -verify 5 -connect registry-1.docker.io:443 < /dev/null 2>/dev/null | openssl x509 -outform PEM | tee ~/registry-1.docker.io.crt
$ openssl s_client -showcerts -verify 5 -connect auth.docker.io:443 < /dev/null 2>/dev/null | openssl x509 -outform PEM | tee ~/auth.docker.io.crt
$ cp ~/k8s.gcr.io.crt /usr/local/share/ca-certificates/
$ cp ~/registry-1.docker.io.crt /usr/local/share/ca-certificates/
$ cp ~/auth.docker.io.crt /usr/local/share/ca-certificates/
$ update-ca-certificates

$ systemctl restart docker

Kind 설치

kind 는 아래 설치 사이트를 참조한다.

https://kind.sigs.k8s.io/docs/user/quick-start/

$ [ $(uname -m) = arm64 ] && curl -Lo ./kind <https://kind.sigs.k8s.io/dl/v0.22.0/kind-darwin-arm64>
$ chmod +x kind
$ mv kind ~/bin/kind

kind config 세팅

image 는 아래 사이트에서 확인 가능하다.

https://github.com/kubernetes-sigs/kind/releases

kind config 는 여기를 참조한다.

https://kind.sigs.k8s.io/docs/user/quick-start/#configuring-your-kind-cluster

ingress 를 사용하기 위해서는 아래 내용 처럼 port mapping 을 해야 한다.

https://kind.sigs.k8s.io/docs/user/ingress

$ vi kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
# patch the generated kubeadm config with some extra settings
kubeadmConfigPatches:
- |
  apiVersion: kubelet.config.k8s.io/v1beta1
  kind: KubeletConfiguration
  evictionHard:
    nodefs.available: "0%"
# patch it further using a JSON 6902 patch
kubeadmConfigPatchesJSON6902:
- group: kubeadm.k8s.io
  version: v1beta3
  kind: ClusterConfiguration
  patch: |
    - op: add
      path: /apiServer/certSANs/-
      value: my-hostname
nodes:
- role: control-plane
  image: kindest/node:v1.29.2@sha256:51a1434a5397193442f0be2a297b488b6c919ce8a3931be0ce822606ea5ca245
#  extraPortMappings:
#  - containerPort: 80
#    hostPort: 10080
#    listenAddress: "0.0.0.0" # Optional, defaults to "0.0.0.0"
#    protocol: tcp # Optional, defaults to tcp
- role: worker
  image: kindest/node:v1.29.2@sha256:51a1434a5397193442f0be2a297b488b6c919ce8a3931be0ce822606ea5ca245
#featureGates:
#  FeatureGateName: true

kind create cluster

$ KIND_EXPERIMENTAL_PROVIDER=docker && kind create cluster --name kind --config kind-config.yaml
--- output ---
Creating cluster "kind" ...
 ✓ Ensuring node image (kindest/node:v1.29.2) 🖼
 ✓ Preparing nodes 📦 📦
 ✓ Writing configuration 📜
 ✓ Starting control-plane 🕹️
 ✓ Installing CNI 🔌
 ✓ Installing StorageClass 💾
 ✓ Joining worker nodes 🚜
Set kubectl context to "kind-kind"
You can now use your cluster with:

kubectl cluster-info --context kind-kind

Thanks for using kind! 😊
  • kind cluster 보기
$ kind get clusters
  • context 로 cluster 조회하기
$ kubectl cluster-info --context kind-kind
  • cluster 삭제하기
$ kind delete cluster --name kind
반응형
Posted by seungkyua@gmail.com
,
반응형

스터디에서 준비해야 할 내용 리스트

배포 전략에 대한 개념 설명

  • Rolling Update
  • Blue/Green
  • Canary
  • Recreate

 

Argo CD 설치, Argo Rollout 설치

  • Argo CD 로 github 을 연동하여 gitops 구현해 보기
  • Argo Rollout 을 활용하여 Blue/Green 구현해 보기
  • Kubernetes 에서 Deployment 로 배포된 서비스가 Rolling Update 되는 로직을 설명
  • (선택) Nginx Ingress Controller 를 활용하여 Canary 배포를 구현해 보기

 

반응형
Posted by seungkyua@gmail.com
,
반응형

Kubernetes 를 세미나를 하거나 모임에서 만나면 쿠버네티스를 알고 싶어 하시는 분들이 제일 문제 물어보는 것이 있다.

"쿠버네티스를 잘 모르는데 이제부터 공부하고 싶어요. 어떤거 부터 하면 좋을까요?"

그 동안은 이는 리눅스를 무엇부터 공부해야 할까요? 와 거의 비슷한 이야기인거 같다. 그래서 키워드로 스스로 공부할 수 있도록 키워드로 리스트를 만들었다.

컨테이너

  • Docker 노트북에 설치
  • 리눅스 네임스페이스 & cgroup
  • dockerfile 작성 및 docker image 만들기
  • docker image 실행/종료/삭제 하기
  • docker hub 에 가입하여 개인 계정에 image 올리기

Kubernetes

  • Kubernetes 는 누가 만들었을까? 어떻게 해서 나오게 된 것일까? (Google Borg 로 검색)
  • CNCF (Cloud Native Computing Foundation) - 이 재단이 어떤 재단인지 알아보기
  • Kubernetes 는 기능이 어떤 것들이 있을까?
    • 기본적으로 Kubernetes 는 컨테이너 이미지를 실행시키는 등 관리하는 역할 수행
  • Kubernetes 노트북에 설치 (아래 3개 중에 하나)
    • kind
    • k3s
    • minikube
  • Kubernetes client 설치
    • kubectl 설치
  • Kubernetes Architecture 확인
    • Control plane
      • etcd (key-value store)
      • kube-apiserver
      • kube-controller
      • kube-scheduler
    • Node
      • kube-proxy
      • kubelet
    • Network plugin
  • Kubernetes 에서 Pod 란 무엇인가?
  • Kubernetes 에 Nginx 이미지를 pod 로 띄우기
    • pod yaml 만들기
    • kubectl 로 pod yaml 을 Kubernetes 에 배포하기
  • Pod 는 어떻게 뜨는 것일까?
    • 사용자가 pod yaml 을 작성
    • 사용자가 kubectl 로 pod yaml 을 kubernetes 로 보냄
    • kubernetes api-server 가 이를 받아서 etcd 에 저장
    • kube-scheduler 가 pod 를 원하는 node 로 스케줄링
    • 스케줄링된 node 에 실행중인 kubelet 이 pod 를 실행
  • Kubernetes Resource 알아보기
    • Pod
    • ReplicaSet
    • Deployment
    • Label & Selector
    • Service
      • ClusterIP
      • NodePort
      • LoadBalancer
    • Ingress & Ingress Controller
    • StatefulSet
    • DaemonSet
    • StorageClass
    • Persistent Volume
    • Persistent Volume Claim
    • ConfigMap
    • Secret
    • ServiceAccount
    • ClusterRole
    • ClusterRoleBinding
  • Custom Controller (Operator 란 무엇인가?)
  • CNCF Projects 알아보기
    • Graduated Projects
    • Incubating Projects
    • Sandbox Projects
반응형
Posted by seungkyua@gmail.com
,
반응형

OPA 실행 파일 설치

$ curl -L -o opa <https://github.com/open-policy-agent/opa/releases/download/v0.62.1/opa_darwin_arm64_static>
$ chmod +x opa
$ mv opa ~/bin/

참고로 홈 디렉토리 아래의 bin 디렉토리에 실행 패스가 잡혀있기 때문에 해당 디렉토리로 이동시킨 것이다.

Intellij 와 Open Policy Agent Plugin 설치

JetBrain 의 IntelliJ 가 있다면 Open Policy Agent 플러그인을 설치하여 rego 프로그램을 작성할 수 있다.

hello 프로그램을 작성하여 실행하여 보자.

먼저 번들용 chap1 디렉토리를 만든다.

$ mkdir -p chap1

chap1 번들 아래에 hello.rego 파일을 만든다.

package hello

default allow_hello = false
default allow_world = false

allow_hello {
    "hello" != ""
}

allow_world {
    "world" != "world"
}

패키지 hello 는 디렉토리와 상관없으며 chap1 디렉토리를 만들었기 때문에 bundle 은 chap1 이다.

allow_hello 와 allow_world 는 rule 을 나타낸다. OPA 1.0 미만은 if 문이 없기 때문에 비교문 만으로 표현한다. OPA 1.0 부터는 if 문을 사용한다.

opa 실행은 input json 에 대한 output json 이 결과로 나오는데 input 소스를 보면 input 이 필요없기 때문에 빈 input.json 파일을 만든다.

$ touch input.json 

현재까지의 디렉토리 구조를 보면 다음과 같다.

rego-example.iml 은 IntelliJ 의 OPA 플러그인에서 사용하는 파일이므로 신경쓸 필요가 없다.

$ tree .
.
├── chap1
│   ├── hello.rego
│   └── input.json
└── rego-example.iml

이를 cli 로 실행하면 다음과 같다.

$ opa eval -f pretty -b chap1 data.hello    

--- output ---
{
  "allow_hello": true,
  "allow_world": false
}

opa eval 명령어로 rule 을 평가할 수 있다.

-f pretty 결과를 보기 쉽게 출력하라는 의미이다.

-b cha1 은 Bundle 을 입력해야 하는데 chap1 디렉토리 의 아래 rego 파일을 실행하다.

data.hello 는 Query 를 의미하며, 여기에는 package 나 rule 을 넣으면 된다. data 는 명시적으로 붙혀서 data.패키지 로 입력하면 된다.

allow_hello rule 을 실행하기 위해서는 query 부분에 rule 까지 넣어주면 된다.

$ opa eval -f pretty -b chap1 data.hello.allow_hello

--- output ---
true

결과 값으로 json 형태가 디퐅트로 출력되는데 -f pretty 를 제거하면 json 으로 결과가 출력된다.

$ opa eval -b chap1 data.hello

--- output ---
{
  "result": [
    {
      "expressions": [
        {
          "value": {
            "allow_hello": true,
            "allow_world": false
          },
          "text": "data.hello",
          "location": {
            "row": 1,
            "col": 1
          }
        }
      ]
    }
  ]
}

input 을 위해서 빈 input.json 파일을 만들었는데 이를 활용하는 명령을 추가할 수 있다.

input 값을 실제로 사용하지는 않기 때문에 결과 값은 동일하다.

$ opa eval -b chap1 -i chap1/input.json data.hello

--- output ---
{
  "result": [
    {
      "expressions": [
        {
          "value": {
            "allow_hello": true,
            "allow_world": false
          },
          "text": "data.hello",
          "location": {
            "row": 1,
            "col": 1
          }
        }
      ]
    }
  ]
}

실행을 cli 로 하지 말고 IntelliJ 에서 실행하는 방법은 아래와 같이 입력하면 된다.

메뉴에서 Run >> Edit Configurations... 을 실행한다.

앞에서 설명한 cli 에서 입력한 내용을 그대로 넣으면 된다.

Run 버튼을 클릭하면 다음과 같이 결과가 나온다.

소스는 아래 사이트에서 다운받을 수 있다. (계속 업데이트 될 예정)

https://github.com/seungkyua/rego-example.git

반응형
Posted by seungkyua@gmail.com
,
반응형

인터페이스가 다른 인터페이서를 가지는 임베딩 방식을 사용하여 인터페이스를 선언할 수 있다. 예를 들어 io.ReadCloser 인터페이스는 io.Reader 와 io.Closer 인터페이스를 가지고 있다.

type Reader interface {
        Read(p []byte) (n int, err error)
}

type Closer interface {
        Close() error
}

type ReadCloser interface {
        Reader
        Closer
}

이 경우 ReadCloser 인터페이스는 아래와 동일 효과를 갖는다.

type ReadCloser interface {
        Read(p []byte) (n int, err error)
        Close() error
}

그런데 인터페이스를 struct 타입 안에 임베딩할 수 도 있다.

이렇게 struct 타입 안에 인터페이스를 넣는 이유는 보통 Stub 으로 유닛 테스트 코드를 만들기 쉽기 때문이다.

아래와 같이 Calculator 라는 스트럭트가 있다고 하자.

type Calculator struct {
    Resolver MathResolver
}

여기에는 MathResolver 인터페이스 타입의 필드를 가지고 있다.

type MathResolver interface {
    Resolve(expression string) (float64, error)
}

이렇게 인터페이스를 만들어 놓으면 MathResolver 를 Stub 으로 구현하여 테스트 코드를 만들 수 있다.

Calculator 는 계산 표현식을 가지고 실제 계산하여 결과 값을 리턴하는 Process 라는 메소스도 가진다.

func (c Calculator) Process(r io.Reader) (float64, error) {
    expression, err := readOneLine(r)
    if err != nil {
        return 0, err
    }
    if len(expression) == 0 {
        return 0, errors.New("no expression to read")
    }
    answer, err := c.Resolver.Resolve(expression)
    return answer, err
}

readOneLine 함수는 계산 표현식을 한 줄만 읽어들이는 함수이며, 이렇게 읽어들인 함수를 MathResolver 타입의 Resolve 함수에 아규먼트로 넘겨서 결과를 받아오는 구조이다.

이제 Proecess 메소드에 대한 테스트 코드를 만들어 보자.

첫번째로 테스트 코드로 MathResolverStub 스트럭트와 Resolve 메소드를 간단히 구현한다.

type MathResolverStub struct{}

func (mr MathResolverStub) Resolve(expr string) (float64, error) {
    switch expr {
    case "2 + 4 * 10":
        return 42, nil
    case "( 2 + 4 ) * 10":
        return 60, nil
    case "( 2 + 4 * 10":
        return 0, fmt.Errorf("invalid expression: %s", expr)
    }
    return 0, nil
}

다음으로 이 스텁을 사용한 테스트 코드를 작성한다.

func TestCalculatorProcess(t *testing.T) {
    c := embed.Calculator{Resolver: MathResolverStub{}}
    in := strings.NewReader(`2 + 4 * 10
( 2 + 4 ) * 10
( 2 + 4 * 10`)

    data := []float64{42, 60, 0}
    expectedErr := errors.New("invalid expression: ( 2 + 4 * 10")
    for _, d := range data {
        result, err := c.Process(in)
        if err != nil {
            if err.Error() != expectedErr.Error() {
                t.Errorf("want (%v) got (%v)", expectedErr, err)
            }
        }
        if result != d {
            t.Errorf("Expected result %f, got %f", d, result)
        }
    }
}

MathResolverStub 를 가지는 Calculator 를 생성하여 Calculator 의 Process 메소드를 테스트할 수 있는 코드를 쉽게 작성할 수 있다.

테스트 코드의 전체 작성은 다음과 같다.

$ mkdir -p interface/embed
$ vi interface/embed/calculate.go

package embed

import (
    "errors"
    "io"
)

type Calculator struct {
    Resolver MathResolver
}

type MathResolver interface {
    Resolve(expression string) (float64, error)
}

func (c Calculator) Process(r io.Reader) (float64, error) {
    expression, err := readOneLine(r)
    if err != nil {
        return 0, err
    }
    if len(expression) == 0 {
        return 0, errors.New("no expression to read")
    }
    answer, err := c.Resolver.Resolve(expression)
    return answer, err
}

func readOneLine(r io.Reader) (string, error) {
    var out []byte
    b := make([]byte, 1)
    for {
        _, err := r.Read(b)
        if err != nil {
            if err == io.EOF {
                return string(out), nil
            }
        }
        if b[0] == '\n' {
            break
        }
        out = append(out, b[0])
    }
    return string(out), nil
}
$ vi interface/embed/calculate_test.go

package embed_test

import (
    "errors"
    "fmt"
    "strings"
    "testing"

    "github.com/seungkyua/go-test/interface/embed"
)

type MathResolverStub struct{}

func (mr MathResolverStub) Resolve(expr string) (float64, error) {
    switch expr {
    case "2 + 4 * 10":
        return 42, nil
    case "( 2 + 4 ) * 10":
        return 60, nil
    case "( 2 + 4 * 10":
        return 0, fmt.Errorf("invalid expression: %s", expr)
    }
    return 0, nil
}

func TestCalculatorProcess(t *testing.T) {
    c := embed.Calculator{Resolver: MathResolverStub{}}
    in := strings.NewReader(`2 + 4 * 10
( 2 + 4 ) * 10
( 2 + 4 * 10`)

    data := []float64{42, 60, 0}
    expectedErr := errors.New("invalid expression: ( 2 + 4 * 10")
    for _, d := range data {
        result, err := c.Process(in)
        if err != nil {
            if err.Error() != expectedErr.Error() {
                t.Errorf("want (%v) got (%v)", expectedErr, err)
            }
        }
        if result != d {
            t.Errorf("Expected result %f, got %f", d, result)
        }
    }
}

실행을 위한 세팅 명령어는 다음과 같다.

$ go mod init github.com/seungkyua/go-test
$ go mod tidy
$ go mod vendor

$ go work init
$ go work use .

go work 는 비지니스 모듈 패키지를 아직 github 에 커밋하지 않은 상태에서 로컬의 최신 패키지 참조를 위해서 필요하다.

전체 소스 트리는 다음과 같다.

$ tree .                   
.
├── README.md
├── go.mod
├── go.work
└── interface
    └── embed
        ├── calculate.go
        └── calculate_test.go

다음은 테스트 실행 결과이다.

$ go test interface/embed/calculate_test.go
ok      command-line-arguments  0.390s

소스는 아래의 링크에서 다운 받을 수 있다.

https://github.com/seungkyua/go-test.git

반응형
Posted by seungkyua@gmail.com
,
반응형

Git 을 사용하다 보면 저장소에 작업한 commit 을 원복해야 하는 경우가 종종 발생한다. 로컬에서 혼자서 작업한다면 reset 을 사용해서 이전 commit 으로 쉽게 돌릴 수 있지만 이미 원격 저장소에 push 한 상태라면 revert 를 사용하여 이전 commit 을 취소하는 새로운 commit 을 만들어야 한다.

명료하게 아래 2가지 경우만 생각하면 된다.

아직 원격 저장소에 push 하지 않은 경우 : reset 사용
원격 저장소에 push 한 경우 : revert 사용

예외적으로 원격 저장소에 push 한 경우라도 reset 을 사용해서 commit 을 돌릴 수 있다. 하지만 이 때는 원격의 commit 도 같이 삭제하는 작업이 필요하므로 git push 를 할 때 -f 으로 강제 push 를 해야하는 문제가 있어 여러명이 함께 작업하는 경우라면 다른 사람들에게 문제가 발생할 수 있다. (웬만하면 하지 말아야 한다)

git reset : 로컬 환경을 특정 commit 위치로 되돌리기

아래와 같은 commit log 가 있다고 보자.

$ git log --oneline -n 5

4e61ca5 (HEAD -> main, origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit
$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 10:15 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 10:16 b.txt
-rw-r--r--@ 1 ask  staff   0  3 14 10:17 c.txt

Initial 커밋에는 README.md 파일이 추가되어 있고, 이후 각 커밋은 a.txt, b.txt, c.txt 가 추가되어 있는 상태이다. 여기서 a.txt 만 남기고 b.txt, c.txt 를 지운 상태의 돌아가고 싶다고 하면 a.txt 를 추가한 be0d36b add a.txt 커밋 상태로 돌아가면 된다.

$ git reset --hard be0d36b
HEAD is now at be0d36b add a.txt

로그와 파일을 조회해 보면 정상적으로 commit 이 이전 상태로 돌아온 것을 알 수 있다.

$ git log --oneline -n 5

be0d36b (HEAD -> main) add a.txt
205a70c Initial commit
$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 10:15 a.txt

reset 명령을 수행하면 커밋이 이전 상태로 돌아간 것이기 때문에 다시 원상태로 돌릴려면 원격 저장소에서 다시 pull 로 가져오면 된다.

$ git pull 

Updating be0d36b..4e61ca5
Fast-forward
 b.txt | 0
 c.txt | 0
 2 files changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 b.txt
 create mode 100644 c.txt
$ git log --oneline -n 5

4e61ca5 (HEAD -> main, origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

파일도 원상태로 생성된 것을 알 수 있다.

$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 10:15 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 10:16 b.txt
-rw-r--r--@ 1 ask  staff   0  3 14 10:17 c.txt

git revert : 이전 commit 제거하는 신규 commit 을 추가

현재 커밋 로그는 다음과 같습니다.

$ git log --oneline -n 5

4e61ca5 (HEAD -> main, origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

여기서 3bd328a add b.txt 을 삭제하고 싶을 때 revert 를 할 수 있다.

$ git revert 3bd328a --no-edit

[main 5a9e9f1] Revert "add b.txt"
 Date: Thu Mar 14 10:51:58 2024 +0900
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 b.txt

git log 를 보면 다음과 같다.

$ git log --oneline -n 5

5a9e9f1 (HEAD -> main) Revert "add b.txt"
4e61ca5 (origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

5a9e9f1 (HEAD -> main) Revert "add b.txt" 커밋 로그를 보면 revert 하면서 새로운 commit 이 생긴 것을 알 수 있다. reset 과는 다르게 commit 의 순서와 내용은 그대로 살아있으면서 revert 가 추가된 것이기 협업할 때 아무런 문제가 없다.

리스트를 보면 b.txt 가 삭제되어 다음과 같다.

$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 10:15 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 10:38 c.txt

revert 와 reset 은 둘 다 파라미터로 commit hash 값을 넣는 것은 동일하나 동작되는 의미는 다르다. reset 은 해당 commit 으로 돌아가기 때문에 그 이후의 commit 은 없어지는 반면에 revert 는 해당 commit 만 제거하는 의미가 있다.

revert 를 다시 revert 할 수 있다

revert 하여 b.txt 를 삭제한 commit 은 5a9e9f1 (HEAD -> main) Revert "add b.txt" 이다. 이 commit 을 revert 하면 다시 b.txt 파일이 살아날 수 있다. revert 할 때 commit hash 값과 이를 가리키는 HEAD 도 같은 의미이기 때문에 HEAD 를 이용하여 revert 해보자.

$ git revert HEAD --no-edit

[main d2b2258] Revert "Revert "add b.txt""
 Date: Thu Mar 14 11:00:28 2024 +0900
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 b.txt
$ git log --oneline -n 10

d2b2258 (HEAD -> main) Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 (origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 10:15 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:00 b.txt
-rw-r--r--@ 1 ask  staff   0  3 14 10:38 c.txt

여러 commit 을 revert 하기

commit hash 값을 나열하면 여러 commit 을 revert 할 수 있다.

3bd328a add b.txt 커밋과 4e61ca5 (origin/main, origin/HEAD) add c.txt 커밋을 동시에 revert 해보자.

$ git revert --no-edit be0d36b 4e61ca5

[main 3b6ada1] Revert "add a.txt"
 Date: Thu Mar 14 11:11:07 2024 +0900
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 a.txt
[main bd91431] Revert "add c.txt"
 Date: Thu Mar 14 11:11:07 2024 +0900
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 c.txt
$ git log --oneline -n 20

bd91431 (HEAD -> main) Revert "add c.txt"
3b6ada1 Revert "add a.txt"
d2b2258 Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 (origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

revert 가 잘 되었지만 각각의 revert 에 대한 커밋이 2개 추가되었다. 3b6ada1 Revert "add a.txt" , bd91431 (HEAD -> main) Revert "add c.txt"

여러 revert 를 하나의 commit 으로 만들기

앞에서 작업한 2개의 revert commit 을 원상태로 되돌려 보자. 원격으로 push 하지 않았으므로 reset 을 사용해도 문제가 없다.

여기서는 d2b2258 Revert "Revert "add b.txt"" 커밋으로 돌아가면 된다.

$ git reset --hard d2b2258
HEAD is now at d2b2258 Revert "Revert "add b.txt""

$ git log --oneline -n 20
d2b2258 (HEAD -> main) Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 (origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 11:19 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:00 b.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:19 c.txt

revert -n 옵션을 사용하면 revert 할 때 index 는 사용하지만 commit 을 하지 않은 상태가 된다. 그러므로 git revert --continue 로 commit 을 진행하면 된다.

 $ git revert -n be0d36b 4e61ca5

현재 상태를 보면 index 에 저장된 상태임을 알 수 있다.

$ git status

On branch main
Your branch is ahead of 'origin/main' by 2 commits.
  (use "git push" to publish your local commits)

You are currently reverting commit 4e61ca5.
  (all conflicts fixed: run "git revert --continue")
  (use "git revert --skip" to skip this patch)
  (use "git revert --abort" to cancel the revert operation)

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    deleted:    a.txt
    deleted:    c.txt

이제 commit 을 하면서 커밋 메세지를 추가할 수 있다.

$ git revert --continue

메세지는 다음과 같이 입력했다.

Revert "add c.txt"
Revert "add a.txt"

git log 를 보면 커밋은 a4b6156 (HEAD -> main) Revert "add c.txt" Revert "add a.txt" 하나만 생성되었음을 알 수 있다.

$ git log --oneline -n 20

a4b6156 (HEAD -> main) Revert "add c.txt" Revert "add a.txt"
d2b2258 Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 (origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 11:00 b.txt

다음 설명을 위해서 다시 reset 을 하자. commit 하나만 뒤로가면 되므로 HEAD^1 을 사용해도 된다.

$ git reset --hard HEAD^1

git revert: merge commit 에 대한 revert 하기

현재 커밋 로그는 다음과 같다.

$ git log --oneline -n 20

d2b2258 (HEAD -> main) Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 (origin/main, origin/HEAD) add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 11:34 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:00 b.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:34 c.txt

git push 를 해서 원격 저장소에 저장한 다음 merge commit 을 만드는 작업을 한다.

$ git push

$ git switch -c merge_branch
$ touch d.txt
$ git add -A
$ git commit -m "add d.txt"

$ git push --set-upstream origin merge_branch 

github 에서 pr 을 올리고 main branch 로 merge 한다.

이후에 main branch 에서 pull 한 다음에 커밋 로그를 보면 다음과 같다.

$ git switch main

$ git pull

$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 11:34 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:00 b.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:34 c.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:44 d.txt

$ git log
commit 409bf49c1b05a39609207da03f28f782c3b8a0b9 (HEAD -> main, origin/main, origin/HEAD)
Merge: d2b2258 9fdd01f
Author: Seungkyu Ahn <seungkyua@gmail.com>
Date:   Thu Mar 14 11:42:42 2024 +0900

    Merge pull request #1 from seungkyua/merge_branch

    add d.txt

commit 9fdd01fb9b0eff870093f15e246c998ae1fac452 (origin/merge_branch, merge_branch)
Author: Seungkyu Ahn <seungkyua@gmail.com>
Date:   Thu Mar 14 11:40:46 2024 +0900

    add d.txt

commit d2b22584cb8108cd7bc1eaaaa5775e1f19f330fa
Author: Seungkyu Ahn <seungkyua@gmail.com>
Date:   Thu Mar 14 11:00:28 2024 +0900

    Revert "Revert "add b.txt""

    This reverts commit 5a9e9f14b9b7403ad5aef1df83d14f1a1d4938dd.

첫번째 커밋 로그를 보면 Merge: d2b2258 9fdd01f 와 같이 Merge 임을 알 수 있다. merge 의 경우 revert 는 -m 옵션으로 첫번재 hash 값을 적용할지 두번째 hash 값을 적용할지를 결정해 주어야 한다.

merge 바로 이전 커밋인 d2b2258 으로 revert 할 것이기 때문에 첫번재를 선택해 준다.

$ git revert 409bf49c -m 1

[main d53c40d] Revert "Merge pull request #1 from seungkyua/merge_branch"
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 d.txt

로그를 보면 revert 되었음을 알 수 있다.

$ git log --oneline -n 20

d53c40d (HEAD -> main) Revert "Merge pull request #1 from seungkyua/merge_branch"
409bf49 (origin/main, origin/HEAD) Merge pull request #1 from seungkyua/merge_branch
9fdd01f (origin/merge_branch, merge_branch) add d.txt
d2b2258 Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit
$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 11:34 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:00 b.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:34 c.txt

patch 로 commit 삭제하기

commit 에 대한 패치 파일을 만들고 -R 옵션을 사용하여 패치 파일을 apply 하면 해당 패치 파일에 대한 commit 을 삭제할 수 있다.

현재 커밋 로그는 다음과 같다.

$ git log --oneline -n 20

d53c40d (HEAD -> main, origin/main, origin/HEAD) Revert "Merge pull request #1 from seungkyua/merge_branch"
409bf49 Merge pull request #1 from seungkyua/merge_branch
9fdd01f (origin/merge_branch, merge_branch) add d.txt
d2b2258 Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit
$ ls -l
total 8
-rw-r--r--@ 1 ask  staff  17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff   0  3 14 11:34 a.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:00 b.txt
-rw-r--r--@ 1 ask  staff   0  3 14 11:34 c.txt

여기서 3bd328a add b.txt 에 대한 패치 파일을 만들어 보자.

$ git format-patch -1 3bd328a

아래와 같이 하나의 0001-add-b.txt.patch 패치 파일이 생성되었다.

$ ls -l
total 16
-rw-r--r--@ 1 ask  staff  368  3 14 13:46 0001-add-b.txt.patch
-rw-r--r--@ 1 ask  staff   17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff    0  3 14 11:34 a.txt
-rw-r--r--@ 1 ask  staff    0  3 14 11:00 b.txt
-rw-r--r--@ 1 ask  staff    0  3 14 11:34 c.txt

이제 -R 옵션을 적용하여 patch 파일을 적용하자. -R 옵션은 reverse 로 패치 파일을 삭제하는 역할을 한다.

$ git apply -R 0001-add-b.txt.patch

상태를 보면 다음과 같다.

$ git status
On branch main
Your branch is up to date with 'origin/main'.

Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    deleted:    b.txt

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    0001-add-b.txt.patch

no changes added to commit (use "git add" and/or "git commit -a")

파일 리스트를 보면 b.txt 가 삭제되어 있다.

$ ls -l
total 16
-rw-r--r--@ 1 ask  staff  368  3 14 13:46 0001-add-b.txt.patch
-rw-r--r--@ 1 ask  staff   17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff    0  3 14 11:34 a.txt
-rw-r--r--@ 1 ask  staff    0  3 14 11:34 c.txt

이제 삭제된 파일을 stage 에 add 한 후 commit 한다.

$ git add b.txt
$ git commit -m "-R patch to b.txt"

커밋 로그를 보면 b.txt 가 삭제된 것을 알 수 있다.

$ git log --oneline -n 20

44e55ce (HEAD -> main) -R patch to b.txt
d53c40d (origin/main, origin/HEAD) Revert "Merge pull request #1 from seungkyua/merge_branch"
409bf49 Merge pull request #1 from seungkyua/merge_branch
9fdd01f (origin/merge_branch, merge_branch) add d.txt
d2b2258 Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

패치 파일이 있으니 apply 로 해당 commit 을 다시 살려보자. 실제로는 commit 을 살리는 것이 아니라 해당 commit 의 변경된 파일을 되살리는 것이다.

$ git apply 0001-add-b.txt.patch
$ git add b.txt
$ git commit -m "restore b.txt using patch"

아래 디렉토리에 b.txt 가 살아난 것을 알 수 있다.

$ ls -l
total 16
-rw-r--r--@ 1 ask  staff  368  3 14 13:46 0001-add-b.txt.patch
-rw-r--r--@ 1 ask  staff   17  3 14 10:12 README.md
-rw-r--r--@ 1 ask  staff    0  3 14 11:34 a.txt
-rw-r--r--@ 1 ask  staff    0  3 14 14:03 b.txt
-rw-r--r--@ 1 ask  staff    0  3 14 11:34 c.txt

필요없는 패치 파일은 삭제한다.

$ rm 0001-add-b.txt.patch

여러 커밋을 하나의 패치 파일로 만들기

현재 커밋 로그는 아래와 같다.

$ git log --oneline -n 20

9aced43 (HEAD -> main, origin/main, origin/HEAD) restore b.txt using patch
44e55ce -R patch to b.txt
d53c40d Revert "Merge pull request #1 from seungkyua/merge_branch"
409bf49 Merge pull request #1 from seungkyua/merge_branch
9fdd01f (origin/merge_branch, merge_branch) add d.txt
d2b2258 Revert "Revert "add b.txt""
5a9e9f1 Revert "add b.txt"
4e61ca5 add c.txt
3bd328a add b.txt
be0d36b add a.txt
205a70c Initial commit

여기서 be0d36b add a.txt , 3bd328a add b.txt, 4e61ca5 add c.txt 을 하나의 패치 파일로 만들고 싶으면 다음과 같이 하면 된다.

시작 hash값 ^.. 종료 hash값

만약 .. 만 사용하면 시간 hash값은 포함되지 않는다(여기서는 be0d36b add a.txt 커밋이 포함되지 않는다). 그러므로 시작 hash값을 포함하고 싶으면 ^.. 을 사용해야 한다.

$ git format-patch be0d36b^..4e61ca5 --stdout > commits.patch

한가지 더 설명하자면 format-patch 는 커밋 히스토리까지 파일에 포함 시킨다. diff 를 사용하면 커밋 히스토리를 제외할 수 있다.

$ git diff be0d36b^..4e61ca5 > diff.patch
반응형
Posted by seungkyua@gmail.com
,