728x90
반응형
  • GraphQL

r/place는 공동 프로젝트이자 사회 실험이었어요. 2017년 만우절에 이어 2022년 만우절에도 반복되었죠.
~ 위키피디아

The GraphQL 팀 네오4j에서 최근에 beta 버전으로 GraphQL subscriptions에 대한 지원을 @neo4j/graphql 라이브러리에 출시했어요. 구독을 통해 클라이언트는 실시간 애플리케이션에서 Neo4j 데이터베이스의 변경 사항을 들을 수 있게 되죠.

2022년에 이 기능을 GraphConnect 컨퍼런스에서 선보일 예정이었는데요, 그래서 유명한 r/place를 기반으로 데모를 만들기로 결정했어요. 이름하여 네오/플레이스!

Example of neo/place colaborative canvas
네오/플레이스

이 데모에는 사용자가 WebSocket을 통해 연결된 모든 사용자에게 모든 변경 사항이 실시간으로 공유되는 위치를 그릴 수 있는 협업 30×30 캔버스가 포함되어 있어요.

단순해 보이지만 neo/place는 확장성을 염두에 두고 구축되었고, GraphQL을 사용해서 프로덕션에 바로 사용할 수 있는 실시간 서비스를 만드는 방법의 좋은 예시가 될 수 있어요.

GraphQL Subscription 작동 방식

neo/place가 어떻게 구축되었는지 자세히 알아보기 전에 GraphQL Subscription이 어떻게 작동하는지 살펴보는 게 좋겠죠? 기본적으로 subscription 작업은 클라이언트와 서버 사이에 오래 지속되는 연결을 생성해요. GraphQL 사양은 이 통신에 어떤 메커니즘을 사용해야 하는지 정의하지 않지만, WebSocket이 subscription을 구축하는 가장 일반적인 방법이에요.

GraphQL 구독

다음은 구독의 예시인데요.

subscription {
  movieCreated {
    createdMovie {
      title
      genre 
    }
  }
}

다른 GraphQL 작업과 마찬가지로 예상되는 결과를 정의할 수 있어요. 하지만 다른 작업과는 달리, 이 결과는 요청에 대한 응답으로 반환되지 않아요. 대신, 클라이언트에 전송되는 후속 메시지()의 예상 형식이 되는 거죠.

GraphQL 작동 방식 (@neo4j/graphql)

Neo4jGraphQL을 사용할 때, 구독은 선택적인 기능으로, 설정되면 자동으로 subscription 엔드포인트가 GraphQL 스키마에 정의된 모든 엔터티에 대해 생성돼요. 이러한 엔드포인트를 통해 create, update, 그리고 delete mutation을 구독할 수 있죠.

Neo4jGraphQL 서비스의 기본 아키텍처

이러한 이벤트는 Neo4j 데이터베이스에 대한 변형 후에 트리거된답니다.

예를 들어, 다음과 같은 type 정의가 있다고 해볼게요.

type Movie { 
  title: String!
  genre: String 
}

그러면 movieCreated, movieUpdated, 그리고 movieDeleted 구독이 일반적인 CRUD 작업과 함께 제공될 거예요. 이렇게 하면 다음과 같은 구독이 가능해지죠.

subscription {
  movieCreated {
    createdMovie {
      title
      genre 
    }
  }
}

...언제든지 새로운 이벤트가 자동으로 실행되는데, 예를 들어 createMovies가 다음과 같이 호출될 때 그렇죠.

mutation { 
  createMovies(input: [{ title: "The Matrix" }]) { 
    movies { 
      title 
    } 
  } 
}

더 많은 예시와 정보는 공식 문서에서 확인하실 수 있어요.

neo/place 구축 방법

Neo/place는 Apollo 서버, @neo4j/graphql 라이브러리, 그리고 데이터베이스를 기반으로 하는 간단한 웹 페이지예요.

GraphQL 스키마

Neo4jGraphQL API와 데이터베이스 스키마는 모두 GraphQL 스키마에 의해 정의돼요. 이 데모에서는 다음 스키마를 사용했답니다.

type Pixel @들어오지 못하게 하다(operations: [CREATE, DELETE]) {
    position: [Int!]! @readonly @고유한
    color: String
}

type Query {
    canvas: [String]
        @사이퍼(
            statement: """
            MATCH(p:Pixel)
            WITH p.color as color
            ORDER BY p.position ASC
            RETURN collect(color) as canvas
            """
        ) @auth(rules: [{isAuthenticated: true}])
}

extend type Pixel @auth(rules: [{isAuthenticated: true}])

데이터베이스에 필요한 유일한 type은 Pixel인데, 캔버스의 한 픽셀 데이터를 가지고 있어요.

  • position: 이 픽셀의 2D 좌표를 보유하는 두 개의 정수 배열이에요. 이러한 좌표는 unique하며 (두 개의 픽셀이 동일한 position을 가질 수 없음) 읽기 전용(position이 설정되면 사용자가 수정할 수 없음)이죠.
  • color: 문자열로 정의된 해당 픽셀의 현재 색상이에요. #FF00FF와 같은 16진수 값을 가지고 있죠.

이 type은 API 보안에 도움이 되는 두 가지 지시문으로도 정의되어 있어요.

  • @exclude: 사용자가 픽셀을 생성하거나 삭제하는 것을 허용하지 않고, 업데이트만 허용해요. exclude는 생성된 API에서 이러한 작업을 제거한답니다.
  • @auth: 인증된 사용자에게만 유효한 정보가 있는지 확인해요. JWT 인증을 통해 픽셀을 수정할 수 있죠. 로그인이 필요 없는 공개 앱이기 때문에 토큰에는 클라이언트와 서버 간에 잘 알려진 비밀번호만 포함될 거예요. 이는 타사 클라이언트가 API를 사용하는 것을 약간 더 어렵게 만드는 순진한 메커니즘이지만, 로그인을 통해 더 나은 보안 메커니즘의 기반이 될 수 있어요.

라이브러리는 필요한 대부분의 CRUD 및 구독 작업을 제공하지만, 성능상의 이유로 사용자 지정 쿼리도 필요해요. 캔버스를 처음 로드할 때 모든 픽셀을 요청해야 하는데, 제공된 CRUD 작업을 사용한다면 쿼리는 다음과 같을 거예요.

query canvas {
    pixels {
        position
        color
    }
}

이 쿼리에 대한 응답은 다음과 같아요.

{
    "pixels": [{
     "position": [0,0],
     "color": "#FFFFFF"
    }, {
     "position": [0,1],
     "color": "#FFFFFF"
    }
    // ...
    ]
}

This json 형식은 일반적으로 대부분의 쿼리에 적합하지만, 이 경우에는 많은 수의 엔터티(30×30 = 900픽셀)를 얻으려고 하며 각 엔터티는 매우 적은 데이터(색상 및 위치)를 보유하고 있어요. 따라서 긴 로딩 시간과 불필요한 네트워킹 로드를 피하기 위해 다음과 같은 사용자 정의 데이터베이스 쿼리를 정의하여 첫 번째 요청을 최적화할 수 있죠. @cypher 지시어를 사용해서요:

MATCH(p:Pixel)
WITH p.color as color
ORDER BY p.position ASC
RETURN collect(color) as canvas

이는 GraphQL 모델을 우회하고 Cypher 데이터베이스에 직접 쿼리하는 거예요. 이 예에서 쿼리는 모든 픽셀을 위치별로 정렬되고 색상만 포함하는 배열로 반환해요. API 응답은 다음과 같을 거예요.

{
  "canvas": ["#FFFFFF", "#FFFFFF", ...]
}

위치별 정렬 덕분에 배열의 첫 번째 항목이 항상 픽셀 [0,0]이고 마지막이 [29,29]이라고 안전하게 가정할 수 있어요. 캔버스 크기를 미리 알고 있는 한 중복된 정보를 제거하고 첫 번째 로드에 대한 간단한 쿼리로 끝낼 수 있는 거죠.

Apollo Server

서버 및 GraphQL 런타임의 경우 Apollo와 함께 graphql-ws를 사용해요.

먼저 Neo4j Driver를 로드하고 우리 데이터베이스에 연결하는 것부터 시작해볼까요?

const driver = neo4j.driver(NEO4J_URL, neo4j.auth.basic(NEO4J_USER, NEO4J_PASSWORD));

Neo4jGraphQL도 가져와서 두 개의 플러그인을 사용하여 타입 정의와 드라이버를 라이브러리 생성자에 전달해 볼게요.

const { Neo4jGraphQLSubscriptionsSingleInstancePlugin, Neo4jGraphQL } = require('@neo4j/graphql');
const { Neo4jGraphQLAuthJWTPlugin } = require("@neo4j/graphql-plugin-auth");

const neoSchema = new Neo4jGraphQL({
  typeDefs: typeDefs,
  driver: driver,
  plugins: {
    subscriptions: new Neo4jGraphQLSubscriptionsSingleInstancePlugin(),
    auth: new Neo4jGraphQLAuthJWTPlugin({
      secret: "super-secret42"
    })
  },
});

우리가 가져오는 두 플러그인은 기능을 향상시키기 위한 공식 플러그인이에요 Neo4jGraphQL:

  • Neo4jGraphQLSubscriptionsSingleInstancePlugin: 구독을 활성화해요.
  • Neo4jGraphQLAuthJWTPlugin: JWT 인증을 활성화하려면 @auth 지시어를 사용해야 해요.

모든 것이 설정되면 Apollo에서 제공할 준비가 된 완전한 GraphQL API를 갖도록 스키마를 구축할 수 있어요.

const schema = await neoSchema.getSchema();

await neoSchema.assertIndexesAndConstraints({ 
  options: { 
    create: true 
  } 
});

이 코드는 또한 @unique 제약 조건이 일관성과 성능을 위해 데이터베이스에 적절하게 설정되어 있는지 확인하기 위해 assertIndexesAndConstraints를 사용해요.

마지막으로 이 스키마를 Apollo 서버에 전달해야 해요.

const server = new ApolloServer({
 schema,
 context: ({ req }) => ({ req }),
});

전체 작업 예시는 다음 링크에서 확인해 보세요.

데이터베이스 모델

여기서는 Neo4j의 Graph Database를 사용할 거예요. 즉, 픽셀 하나하나가 데이터베이스의 그래프에서 "Node"로 모델링된다는 뜻이죠.

앞서 말씀드렸듯이, 사용자는 픽셀을 수정할 수는 있지만 새로 만들거나 삭제할 수는 없어요. 서버가 시작될 때 데이터베이스를 채워줘야 하는데, 이를 위해 Neo4j 드라이버에서 직접 Cypher 쿼리를 실행할 거예요.

const session = driver.session();
await session.run(`
  UNWIND range(0, 29) AS x
  UNWIND range(0, 29) AS y
  MERGE(p:Pixel { position: [x, y]})
  ON CREATE SET p.color="#FFFFFF"
`);
await session.close();

이 쿼리는 [0,0]부터 [29, 29]까지의 위치에 새로운 엔티티를 만들고, 기본 색상으로 흰색(#FFFFFF)을 설정해요. MERGE를 사용하면 아직 존재하지 않는 경우에만 엔티티가 생성된답니다.

데이터베이스가 채워진 모습

이 데모에서는 Neo4j AuraDB Free를 사용해서 모든 호스팅과 관리가 클라우드에서 이루어졌어요.

neo/place 클라이언트 구축

클라이언트는 일반적인 HTML 웹 페이지로 구성되어 있고, 공동 그림을 표시하는 <canvas> 엘리먼트와 색상을 선택할 수 있는 몇 개의 버튼이 있어요.

<canvas id="place" height="300px" width="300px"></canvas>

<button class="color-button" style="background-color: rgb(204, 37, 75);"></button>
<button class="color-button" style="background-color: rgb(1, 139, 255);"></button>
<!-- .... -->

서버에 연결하기

neo/place의 요구 사항을 충족하려면 서버에 세 개의 GraphQL 쿼리만 있으면 돼요. 이 쿼리들을 실행하기 위해 GraphQL 클라이언트인 urql과 graphql-ws를 함께 사용하기로 했어요.

서버 API에 대한 간단한 래퍼를 만드는 것부터 시작해볼게요.

먼저, 초기 캔버스를 렌더링하는 데 사용할 커스텀 canvas 엔드포인트를 호출해야 해요.

async getCanvas() {
  const canvasQuery = gql`
    query Canvas {
      canvas
    }
  `;
  const result = await this.client // urql client
    .query(canvasQuery)
    .toPromise()
  if (result.error) throw new Error(result.error.message)
  return result.data.canvas
}

이 메서드는 나중에 캔버스를 채우는 데 사용할 색상 배열을 반환해준답니다.

‘

그리고 픽셀이 수정될 때마다 서버 이벤트를 수신해야 해요. 이를 위해 subscription 쿼리를 사용해서 수신된 모든 이벤트에 대한 콜백에 연결할 거예요.

onPixelUpdate(cb) {
  const pixelsSubscription = gql`
    subscription Subscription {
      pixelUpdated {
        updatedPixel {
          position
          color
        }
      }
    }
  `;

pipe(
  this.client.subscription(pixelsSubscription), // urql client
  subscribe((result) => {
   if (!result.error) {
    cb(result.data.pixelUpdated)
   }
  })
 );
}

마지막으로, 사용자가 클릭할 때마다 데이터베이스의 픽셀을 업데이트해야겠죠?

updatePixel(position, color) {
  const updatePixelQuery = gql`
    mutation UpdatePixels($update: PixelUpdateInput, $where: PixelWhere) {
      updatePixels(update: $update, where: $where) {
        pixels {
          position
          color
        }
      }
    }
 `;
  const params = {
    update: {
      color
    },
    where: {
      position
    },
  };

  return this.client.mutation(updatePixelQuery, params).toPromise();
}

캔버스와 상호 작용하기

캔버스는 화면에 보이는 300×300 픽셀의 정적인 캔버스예요. 하지만 우리는 30×30 그리드를 표시하려고 해요. 각 '픽셀'은 사용자가 단일 색상으로 그릴 수 있는 10×10 타일로 렌더링될 거예요.

Grid

API와 마찬가지로 캔버스 상호 작용을 처리하기 위한 몇 가지 도우미 메서드를 만들어 볼게요.

먼저 픽셀을 10×10 타일로 그릴 수 있어야 해요.

function drawPixel(pixel, color) {
  const ctx = canvas.getContext("2d");
  const x0 = pixel[0] * 10;
  const y0 = pixel[1] * 10;
 
  ctx.fillStyle = color;
  // We don't want our browser to smooth our pixels
  ctx.imageSmoothingEnabled = false;

  // Paint a rectangle of 10x10 in the given coordinates
  ctx.fillRect(x0, y0, 10, 10);
}

그리고 사용자가 클릭하는 픽셀을 받아야겠죠?

function onPixelClicked(cb) {
  canvas.addEventListener("click", (ev) => {
    const screenPixel = [ev.clientX, ev.clientY];
    const rect = canvas.getBoundingClientRect();
    
    const x = screenPixel[0] - parseInt(rect.left);
    const y = screenPixel[1] - parseInt(rect.top);
 
    const pixelClicked = [
      parseInt(x / 10), 
      parseInt(y / 10)
    ];

    // Calls the callback to handle interaction with the API
    cb(pixelClicked); 
 }, false);
}

모든 것을 하나로 연결하기

이 두 도우미를 사용하면 캔버스 상호 작용을 API에 쉽게 연결하고, 구독에서 받은 변경 사항을 캔버스에 반영할 수 있어요.

첫 번째 요청 시 캔버스를 그려볼게요.

// Receives the array with all the canvas colors
const canvasColors = await serverApi.getCanvas(); 
let i = 0, j = 0;
for (const pixelColor of canvasColors) {
  drawPixel([i, j], pixelColor);
  j++;
  if (j === 30) { // new line in the canvas
    i++;
    j = 0;
  }
}

구독 이벤트가 올 때마다 캔버스를 업데이트해야 해요.

serverApi.onPixelUpdate((updatedEvent) => {
  const updatedPixel = updatedEvent.updatedPixel;    
  drawPixel(updatedPixel.position, updatedPixel.color);
});

마지막으로, 사용자가 캔버스를 업데이트할 때마다 서버에 알려줘야겠죠?

let selectedColor = "#FF0000"

onPixelClicked((pixelClicked) => {
  // Draw pixel immediately to provide instant feedback to the user
  drawPixel(pixelClicked, selectedColor);
  // Updates pixel in the database
  serverApi.updatePixel(pixelClicked, selectedColor);
});

경합 조건 및 오류 처리를 포함한 전체 코드 예제를 보려면 다음에서 예제를 확인하세요.

수평 스케일

앞서 언급했듯이 데모는 코드가 많이 포함되지 않아 비교적 간단하지만, 수평으로 확장되는 프로덕션 환경을 보여드리겠다고 약속했죠. 이 데모의 다양한 시스템을 확장하려면 어떤 변경이 필요한지 한번 살펴볼까요?

데이터베이스

클라우드 호스팅을 사용하고 있기 때문에 AuraDB를 사용하면 데이터베이스 확장은 더 높은 티어로 전환하는 것만큼 간단해요. 하지만 구독은 데이터베이스에 추가적인 `Query`나 오버헤드를 발생시키지 않도록 설계되었기 때문에, 데이터베이스는 우리가 실행하는 비교적 간단한 `Query`와 변형에 대해서만 신경 쓰면 돼요.

즉, 로드가 심한 경우에도 모든 `Query`는 `Index`된 `Node`에 대한 간단한 속성 업데이트에요 (@unique 우리가 이미 설정한 위치에 대한 제약은 `Index` 역할을 하죠). 무료 등급에서도 데이터베이스의 성능 한계에 도달하려면 높은 로드가 필요하므로 지금은 무료 인스턴스를 계속 사용해도 괜찮을 것 같아요.

서버 및 AppEngine

Apollo로 Node.js 서버를 호스팅하기 위해 Google 클라우드의 AppEngine에 배포하기로 결정했어요. 이렇게 하면 서버 자체가 로드에 따라 확장되죠. AppEngine이 로드 밸런싱(WebSocket 연결 포함)과 자동 확장을 처리하므로 이 부분에 대해서는 Google 측에서 추가 작업이 필요하지 않아요.

AMQP로 구독 확장

WebSocket 서비스와 마찬가지로 구독은 전통적으로 확장이 어려워요. 클라이언트가 특정 인스턴스를 구독하는 동안, 해당 구독에 대해 알지 못하는 완전히 별도의 인스턴스에서 변형이 발생할 수 있거든요.

여러 인스턴스를 구독하는 데 문제가 있습니다.

안타깝게도 이 문제는 로드 밸런서나 AppEngine으로 해결될 문제가 아니에요. 클라이언트가 모든 서버를 구독하거나, 단일 인스턴스를 사용해서 모든 구독을 처리하게 되면 서버 확장 목적이 무효화되죠.

이 문제에 대한 가장 좋은 해결책 중 하나는 별도의 브로커를 사용하는 건데요, 예를 들어 Redis나 RabbitMQ 같은 걸 활용하면 모든 인스턴스에서 메시지를 효율적으로 처리하고 브로드캐스트할 수 있어요.

별도의 브로커로 설정된 여러 인스턴스

즉, 각 인스턴스는 구독과 관련해서 두 가지 작업을 해야 해요.

  1. 변형이 발생할 때마다 브로커에게 알려주기
  2. 브로커를 수신하고, 이 이벤트를 구독한 클라이언트로 라우팅하기

추가적으로, Neo4jGraphQL은 브로커가 수신한 메시지를 클라이언트에 보내기 전에 필터링 및 인증을 처리해준답니다.

이 모든 게 꽤 많은 작업처럼 들릴 수도 있지만, Neo4jGraphQL362434

  • GraphQL
  • 자바스크립트
  • r/장소

에이치시스템즈의 LogTree는 Neo4j 기반 GraphRAG 플랫폼으로, 데이터를 자동으로 지식그래프화하고 자연어 질의로 즉시 답을 제공합니다.

👉 에이치시스템즈 홈페이지

728x90
반응형

+ Recent posts