JWT에 대하여

JWT의 정의

Json Web Token의 줄임말이다. RFC 7519에 명세되어 있는 국제 표준으로써, 통신 양자간의 정보를 JSON 형식을 사용하여 안전하게 전송하기 위한 방법이다. JWT는 정보가 토큰 자체에 포함된(Self-Container) 클레임(Claim) 기반 토큰이다.

 

Claim은 JWT 내부에 포함된 정보의 단위로, 특정 주체(사용자, 시스템 등)에 대한 속성이나 권한을 나타낸다. 클레임은 JWT의 payload 부분에 JSON 형식으로 포함되며, 토큰이 발급될 때 함께 서명되어 변경이 불가능하게 된다.

 

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true,
  "iat": 1516239022
}

 

위의 경우, sub, name, admin, iat와 같은 클레임이 포함되어 있다. 각 클레임은 토큰을 사용하는 어플리케이션이나 시스템에서 특정 주체에 대한 정보를 표현하고, 토큰을 수신한 쪽에서 이를 기반으로 인증 및 인가 과정을 수행할 수 있다.

 

JWT는 인증(Authentication)권한부여(Authorization) 에 사용되는 것이 가장 일반적이다. 인증 절차를 거쳐 서버에서 JWT를 발급해주면, 클라이언트는 이를 잘 보관하고 있다가 API 등을 사용할 때에 서버에 JWT를 함께 제출하며 서버로 부터 행위에 대해 인가 받을 수 있다.

 

JWT는 해시 혹은 비대칭키 방식을 사용하여 서명(Signatrue) 하기 때문에 무결성을 검증할 수 있다는 특징이 있다. 또한 토큰 자신이 정보를 직접 포함하고 있는 특징 덕분에, 통신 양자간 정보를 안전하게 전송할 때에도 사용된다.

해시(Hash): 해시 함수는 임의의 길이를 가진 입력 데이터를 고정된 길이의 값으로 변환하는 함수이다. 이 값은 해시값(Hash Value) 또는 해시코드(Hash Code)라고 불린다. 해시 함수의 주요 특징은 다음과 같다.
    - 고정된 길이의 출력: 입력 데이터의 길이에 관계없이 항상 동일한 길이의 출력을 생성
    - 단방향성: 해시값으로부터 원래의 입력 데이터를 복원하는 것은 거의 불가능
    - 충돌 회피: 서로 다른 두 입력이 동일한 해시값을 갖는 경우(충돌)가 극히 드물다.

비대칭키(Asymmetric Key): 비대칭키 암호화는 공개키 암호화라고도 하며, 두 개의 키 쌍(공개키와 개인키)을 사용한다.
    - 공개키(Public Key) : 누구나 알 수 있는 키로, 데이터를 암호화하거나 서명을 검증하는데 사용된다.
    - 개인키(Private Key): 소유자만 알고 있는 비밀 키로, 데이터를 복호화하거나 서명하는 데 사용된다.

    비대칭키 암호화의 주요 특징은 개인키로 서명한 데이터를 공개키로 검증할 수 있다는 점이다. 

서명(Signature): 서명은 데이터의 출처를 확인하고 무결성을 검증하는 데 사용된다. JWT는 다음과 같이 이루어진다.
    1. JWT의 header와 payload를 결합한 후 hash function을 사용하여 hash value를 생성한다. 
    2. 이 hash value를 private key로 암호화하여 서명을 생성한다.

    서명된 JWT를 받은 수신자는 다음과 같이 서명을 검증한다.

    1. JWT의 header와 payload를 결합한 후 동일한 hash function을 사용하여 hash value를 생성한다.
    2. public key를 사용하여 JWT에 포함된 서명을 복호화하여 hash value을 얻는다.
    3. 두 hash value가 일치하면 데이터가 변경되지 않았음을 확인할 수 있다. 

무결성(Integrity) : 무결성은 데이터가 전송 중에 변조되지 않았음을 보장하는 특성이다. JWT의 무결성은 서명을 통해 보장된다. 서명이 포함된 JWT는 수신자가 서명을 검증하여 토큰의 payload(클레임 데이터)가 전송 중에 변조되지 않았는지 확인할 수 있다. 

 

또한 JWT는 URL에 대한 안전한(URL-Safe) 문자열로 구성되어 있어 어떤 경로로든 전송할 수 있다.



서버기반 인증 VS 토큰기반 인증

 

HTTP는 무상태(Stateless) 프로토콜의 일종이다. 무상태 프로토콜이란, 각 요청을 독립적인 트랜잭션으로 취급하여 모든 상태가 어디에도 저장되지 않는다는 특성이다. 즉, 이전 요청과 현재 요청은 서로 관련이 없음을 뜻한다.

 

따라서 HTTP 그 자체만으로는 사용자가 아무리 인증에 성공한다고 하더라도, 그 인증 상태가 어디에도 저장되지 않는다. 로그인하고, 다음 페이지로 이동한 뒤, 또 로그인을 해야하는 이상한 서비스는 아무도 사용하지 않을 것 이다.

 

이러한 한계를 극복하기 위해 여러 인증 방법이 등장하게 되었고, 그 중 서버기반 인증과 토큰기반 인증 두가지에 대해 알아보겠다.



서버기반 인증

 

서버기반 인증에서는 사용자가 성공적으로 로그인한 이후 서버에서 사용자에 대한 세션(Session) 을 생성한다. 또한 이와 동시에 사용자의 브라우저에서 세션 ID를 저장하는 쿠키가 생성된다. 서버는 이 세션 ID를 통해 사용자를 식별하고, 사용자에 대한 정보를 저장, 관리한다.

 

서버에서 사용자에 대한 모든 정보를 갖고 있다. 라는 특징을 살펴보자. 만일 우리가 운영하는 서비스의 사용자가 증가하게 되어 서버를 확장해야 하는 상황에 직면했다고 가정하자.

 

이 경우 CPU를 더 좋은 것으로 교체하고, HDD/SDD 용량을 더 큰 것으로 교체하는 등의 방법을 가장 먼저 생각할 수 있다. 허나 단일 컴퓨터의 장비를 고성능으로 교체하는 방식의 확장을 수직 확장(Scale Up) 이라고 한다.

 

하지만, 이는 경제적으로 부담이 된다. 또한 한대의 서버만으로 운영하기에, 이 서비스는 단일 장애 지점(SPOF: Single Point of Failure) 를 갖게 되며, 서버가 다운되면 모든 서비스는 접속이 불가능해 질 것 이다.

 

단일 장애 지점: 시스템 내에 단일 요소가 고장 날 경우, 전체 시스템이 중단되는 지점을 의미한다. 이는 신뢰성과 가용성을 크게 저하시킬 수 있는 잠재적인 약점이다.

 

이런 이슈가 존재해 여러 대의 서버를 한 번에 운용하는 방법을 선택하는 것으로 방향을 틀었다고 가정해보자. 이렇게 서버를 여러 대 두어 확장하는 방식을 수평 확장(Scale Out) 이라고 한다.

 

수직 확장의 경제적 부담과 비교하여, 수평 확장은 동일한 사양의 컴퓨터 한대만 추가하고 제거하면서 스케일 관리를 할 수 있어 경제적으로 훨씬 부담이 덜하며 확장에 유연하다.

 

또한 하나의 서버 컴퓨터가 죽더라도, 다른 컴퓨터가 그대로 역할을 이어받으면 그만이다. 안전성 측면에서 바라봐도 우위에 있다고 할 수 있다. 이런 특징은 특히 사용자 증가폭 예측이 어려운 서비스에서 빛을 발하게 된다.

 

이런 Scaling 이슈로 인해 현대의 대규모 트래픽 처리와 데이터 저장이 필요한 서비스들은 일반적으로 수평적으로 서버를 확장한다.

 

하지만, 여러 대의 서버를 사용하면 데이터 불일치의 문제가 발생할 수 있다. 즉, 모든 서버가 메모리에 동일한 세션 정보를 가지고 있는 것이 아니다. 유저가 서비스 사용 중에 로그인이 풀리는 경험을 겪을 수 있다는 것이다.

 

이를 해결하기 위해 운용중인 모든 서버 컴퓨터에서 유저의 세션 ID를 모두 공유해야 한다. 이는 매우 번거로운 일이다.



토큰기반 인증

세션기반 인증에서는 세션 정보는 서버 메모리 위에 저장된다고 하였다. 세션 정보에는 유저의 ID, 이름, 권한 등 유저의 여러 정보가 포함될 수 있다.

 

이에 반해 토큰기반 인증방식은 유저의 정보를 서버에 저장하지 않는다. 유저가 성공적으로 로그인하면, 서버는 클라이언트로 토큰(가장 일반적으로 JWT가 사용됨)을 발급한다.

 

클라이언트는 토큰을 받아 저장하고, 서버에 요청할 때 HTTP header에 실어 함께 전송한다. 서버는 이를 검증(Verification) 하고, 유저를 인가(Authorization)한다. 이와 같이 서버는 '발급''검증' 두 가지 역할만 할 뿐 직접 정보를 갖고 있지 않다. 유저 상태의 저장 책임이 서버에서 클라이언트로 이동된 것이다.

 

이 말인 즉슨, 수평 확장의 환경에서 여러 대의 서버 컴퓨터가 모두 유저에 대한 정보를 기억하고 있을 필요가 없다는 뜻이다.

 

하지만, JWT 와 같이 토큰 자체에 정보가 저장되는 형태의 토큰은 세션과 달리 클라이언트에 유저의 정보가 저장되므로 노출되기 매우 쉽다. 따라서 민감한 정보를 담아서는 절대 안된다. 또한 토큰의 사이즈는 세션 ID에 비해 굉장히 비대하다. 토큰 기반 인증을 사용하면 이런 토큰을 사용하여 통신하면서 발생하는 오버헤드를 감안해야 한다는 단점이 존재한다.

 

뭐든지 '절대적으로' 좋은 것은 존재하지 않는다. 좋은 개발자는 존재하는 기술을 적재적소에 사용할 수 있어야 한다.

 

예전에는 의미없는 랜덤 문자열등을 생성해서 토큰 기반 인증을 구현하였는데, 이 토큰에는 만료시각 등의 정보를 담을 수 없어, 따로 만료시킬 수단이 존재하지 않는다. JWT 같은 경우 데이터를 직접 갖고 있는 클레임(Claim) 기반 토큰이므로 토큰의 만료를 구현할 수 있게 되었다.



JWT의 구조

토큰은 헤더(Header), 페이로드(Payload), 서명(Signature) 세 부분으로 구성되어 있다. 각 구성요소는 점'.'으로 분리된다. 따라서 JWT는 헤더, 페이로드, 서명의 형태를 갖는다.

 

각각의 구성요소는 JSON 형태로 표현된다. 다만, JSON의 경우 개행을 포함할 수 있어, 이를 한 줄로 나타내기 위해 최종적으로는 각 구성요소를 Base64로 인코딩한다.

JSON 형식의 데이터는 사람이 읽기 쉽게 작성될 수 있기에 개행 문자(줄바꿈 문자)를 포함할 수 있다. 이는 데이터를 여러 줄로 표현하는 것이다.



헤더(Header)

헤더는 일반적으로 토큰의 유형과 암호화 알고리즘 두 가지 정보를 아래와 같이 JSON의 형태로 담고 있다.

 

{
  "alg": "HS256", // algorithm: JWT를 서명하는 데 사용된 알고리즘
  "typ": "JWT" // type: 토큰의 타입을 지정
}

 

alg에 넣어둔 암호화 알고리즘은 주로 HMAC SHA256, RSA가 사용된다. 이는 후술할 서명(Signature)에서 사용된다.



페이로드(Payload)

Payload는 사용자의 정보 혹은 데이터 속성 등을 나타내는 클래임(Claim)이라는 정보 단위로 구성된다. 클래임도 3가지로 구분할 수 있는데 각각 등록된 클레임(Registered Claim), 공개 클레임(Public Claim), 비공개 클레임(Private Claim) 으로 구성되어 있다.



등록된 클레임(Registered Claim)

JWT 사양에 이미 정의된 클레임이다. 아래의 7개의 등록된 클레임이 정의되어 있다. 모든 클레임은 선택적이다. token size를 작게 유지하기 위해 이름이 3글자로 축약되어 있는 것을 확인할 수 있다.

 

  • iss: Issuer, 토큰 발급자를 나타낸다.
  • sub: Subject, 토큰 제목을 나타낸다.
  • aud: Audience, 토큰 대상자를 나타낸다.
  • exp: Expiration Time, 토큰 만료 시각을 나타낸다. Numeric Date 형식으로 나타낸다.
  • nbf: Not Before, 토큰의 활성 시각을 나타낸다. 쉽게 말해, 이 시각 적에는 토큰이 유효하지 않다는 의미이다. Numeric Date 형식으로 나타낸다.
  • iat: Issued At, 토큰이 발급된 시각을 나타낸다. Numeric Date 형식으로 나타낸다. 이 값으로 토큰이 발급된지 얼마나 오래됐는지 확인할 수 있다.
  • jti: JWT ID, JWT의 식별자를 나타낸다.



공개 클레임(Public Claim)

공개 클레임은 JWT를 사용하는 사람들에 의해 정의되는 클레임으로, 충돌 방지를 위해 URI 형태로 이름을 짓거나, IANA JSON Web Token Claims Registry 라는 곳에 직접 클레임을 등록해야 한다.

 

사실 단순히 서버와 클라이언트 사이에서 사용자를 인증하는 용도로 사용한다면 크게 신경 쓰지 않아도 좋다. 서버-클라이언트 사이의 단순 통신을 넘어 제 3자도 JWT 토큰을 사용할 때 충돌이 일어나지 않도록 합의된 클레임이라고 생각하면 된다.

 

{
  "email": "sample@domain.com",
  "profile": "http://domain.com/image.png",
  "http://domain.com/xxx/yyy/is_admin": true
}

 

위 처럼 등록된 공개 클레임인 email, profile 등을 사용할 수도 있고, http://domain.com/xxx/yyy/is_admin 처럼 URI 형태로도 사용할 수 있다.



비공개 클레임(Private Claim)

서버와 클라이언트 사이에서만 협의된 클레임으로, 공개 클레임과 충돌이 일어나지 않게 사용하면 된다.

 

{
  "user_id": "123456790",
  "user_age": 25
}



서명(Signature)

 

특정 암호화 알고리즘을 사용하여, Base64 인코딩된 헤더와 Base64 인코딩된 Payload 그리고 비밀키를 이용하여 암호화한다. 서명을 통해 서버는 헤더 혹은 payload가 누군가에 의해 변조되었는지 그 무결성을 검증하고 보장할 수 있다.

 

HMAC SHA256을 사용할 서명 생성을 아래와 같은 수도코드(pseudo-code) 로 나타낼 수 있다.

 

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)



JWT 직접 만들어보기

이제 JWT의 원리를 알아보았으니, 직접 JWT를 만들어본다. 개발 환경은 작성 기준 node.js LTS 버전인 v16.13.2을 사용한다.

 

Header

{
  "alg": "HS256",
  "typ": "JWT"
}

 

Payload

{
  "email": "devhudi@gmail.com",
  "name": "Hudi",
  "isAdmin": true
}

헤더에 나와있듯 암호화 알고리즘은 HMAC SHA256 (HS256) 을 사용한다.



사전준비

app.js 파일을 생성하고, 아래와 같이 crypto 모듈을 불러온다. 이는 node.js에서 암호화 등의 작업을 할 때 사용되는 모듈이다.

 

const crypto = require("crypto")

 

또한 아래와 같이 json 형태의 객체를 Base64 로 인코딩 해주는 함수를 작성하자.

 

function base64(json) {
  const stringified = JSON.stringify(json)
  // JSON을 문자열화
  const base64Encoded = Buffer.from(stringified).toString("base64")
  // 문자열화 된 JSON 을 Base64 로 인코딩
  const paddingRemoved = base64Encoded.replaceAll("=", "")
  // Base 64 의 Padding(= or ==) 을 제거

  return paddingRemoved
}

 

Base64로 문자열을 인코딩 하면, 결과물 마지막에 = 혹은 ==가 가끔 같이 나오는 경우가 존재한다. 이를 Padding이라고 하는데, 이를 제거하지 않으면 URL Safe 하지 않게 되므로 반드시 제거하자. 제거해도 Decode를 정상적으로 할 수 있다.

 

혹시 replaceAll에서 replaceAll is not a fuction 오류가 발생하는가? node.js 버전이 v15보다 낮은지 확인해 보자. replaceAll는 ES2021(ES12)에서 공식 스펙으로 포함되었다.



Header 만들기

const header = {
  alg: "HS256",
  typ: "JWT",
}

const encodedHeader = base64(header)
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

 

단순히 header JSON을 base64 인코딩 해준다.



Payload 만들기

const payload = {
  email: "devhudi@gmail.com",
  name: "Hudi",
  isAdmin: true,
}

const encodedPayload = base64(payload)
// eyJlbWFpbCI6ImRldmh1ZGlAZ21haWwuY29tIiwibmFtZSI6Ikh1ZGkiLCJpc0FkbWluIjp0cnVlfQ



Signature 만들기

const signature = crypto
  .createHmac("sha256", "secret_key")
  .update(`${encodedHeader}.${encodedPayload}`)
  .digest("base64")
  .replaceAll("=", "")

// KeefPR1ixDwoNnBQ77YsBYQxXFkZR1VcAkah6yle5lk

 

인코딩된 Header와 Payload를 점(.)으로 이어 붙인 것은 SHA256 알고리즘을 사용하여 HMAC으로 암호화 한다. 이 또한 Base64 로 표현하도록 설정한다. 마찬가지로 Padding을 제거한다.

 

HMAC (Keyed-hash Message Authentication Code) 이란, 메시지 인증 코드 (MAC) 의 한 유형으로서 특정 Key 와 함께 특정 Message 를 Hash 값으로 만드는 암호화 방식이다. 공격자로 하여금 레인보우 테이블 기법의 해킹을 어렵게 하기 위해 원문과 함께 비밀키를 더하여 해싱하는 것 이다.



조합하기

const jwt = `${encodedHeader}.${encodedPayload}.${signature}`

최종 결과물은 다음과 같다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImRldmh1ZGlAZ21haWwuY29tIiwibmFtZSI6Ikh1ZGkiLCJpc0FkbWluIjp0cnVlfQ.KeefPR1ixDwoNnBQ77YsBYQxXFkZR1VcAkah6yle5lk



검증하기

JWT 토큰을 가장 쉽게 검증하는 방법은 jwt.io에 접속하는 것이다. 웹 사이트에 접속한 후 먼저 우측 하단 'VERIFY SIGNATURE'의 your-256-bit-secret를 우리의 비밀키 secret-key로 변경하자. 그 다음 우리가 생성한 JWT를 좌측에 붙여넣는다.



JWT 사용 시 주의사항

 

JWT는 세션과 달리 무상태(Stateless) 한 특징을 가지고 있다. 서버에서는 아무런 정보도 가지고 있지 않으며, 토큰 자체의 만료일자까지 토큰 자신이 가지고 있다. 만약 이런 토큰을 누군가 탈취해간다면? 누구나 토큰을 탈취 당한 사람의 계정에 접근할 수 있게 될 것 이다.

 

하지만, 서버에서는 JWT가 만료될 때까지 아무런 조취도 취할 수 없으며, 그저 바라만보고 있어야 한다. 즉, 이미 발행된 토큰에 대해 서버는 아무런 제어도 할 수 없다.

 

수 년전 이런 취약점을 악용하여, 페이스북 유저들의 토큰을 피싱사이트를 이용해 탈취하여 악용하는 사례가 굉장히 많이 발생한 적이 있어 시끄러웠던 적이 있었다.

 

이런 취약점을 막기 위해 현재는 많은 서비스들이 Access Token과 Refresh Token을 함께 사용하는 방식을 채택한다. 이 방식은 Access Token으로 사용자를 인가하지만, 그 만료 기간이 매우 짧다.(5분, 1시간, 24시간 등 다양하지만, 1일을 넘지 않는게 보통)

 

Access Token이 만료되면, 클라이언트는 같이 발급된 Refresh Token을 이용하여 서버에 Access Token 재발급을 요청한다. Refresh Token은 약 2주가량 만료기간을 길게 잡는다.

 

하지만, Refresh Token까지 탈취된다면 공격자는 Access Token을 발급받아 탈취자인양 행세를 할 수 있으므로, 이에 대한 조치도 추가로 필요하다.



마치며

생각보다 JWT 자체를 생성하는 것은 그리 거창한 작업은 아니다. 하지만, 이런 단순 작업은 라이브러리에게 맡기는 것이 훨씬 편할 것 이다. node.js 진영에서는 jsonwebtoken이라는 가장 대중적으로 사용되는 JWT 라이브러리가 존재한다.

 

본 포스팅에서는 원리를 직접 알아보기 위해 일일히 인코딩, 암호화 하여 JWT를 생성하였지만, 실제 제품을 개발할 때에는 상용 라이브러리를 쓰는 것을 추천한다.

'인터넷 기본 지식' 카테고리의 다른 글

#11. CORS, preflight  (0) 2024.06.24
#9. RESTful 웹 API 디자인  (1) 2024.06.19
#8. LSP  (2) 2024.06.10
#7. 프로그래밍 패러다임  (1) 2024.06.09
#6. DNS(Domain Name System)  (0) 2024.05.31

Middleware 함수는 어플리케이션의 request-response cycle에서 next 함수와 request object와 response object에 접근할 수 있다.
next 함수는 Express의 router 함수로서, 호출되면 현재 미들웨어 다음에 실행될 미들웨어를 실행한다.

 

다음과 같은 역할을 담당할 수 있다.

  1. 특정한 code를 실행할 수 있다.
  2. request, response object들을 바꿀 수 있다.
  3. request-response object를 끝낼 수 있다.
  4. stack에서 next middleware를 호출할 수 있다.

 

만일 현재 middleware function이 request-response cycle을 끝내지 못한다면, 반드시 next() 를 호출하여 제어권을 다음 middleware function에 넘겨줘야 한다.

 

import * as express from "express";
import { Cat, CatType } from "./Cat/CatType";
import { error } from "console";

const app: express.Application = express();

// 아래의 router들을 살펴보면 동일한 logging 작업이 반복된다.
// 이를 middleware에서 처리하도록 한다. middleware는 맨 위에 적어줘야 한다
// 만일 맨 끝에 작성하게 되면 동작을 하지 않는다.

// app.use: router 전체에 대한 middleware
app.use((req, res, next) => {
  // next 함수: 다음 router로 이동할 수 있게 하는 함수.
  console.log("========================================");
  console.log("this is logging middleware");
  console.log(req.rawHeaders[1]);
  console.log("========================================");
  next();
});

app.get("/cats/som", (req, res, next) => {
  // next 함수: 다음 router로 이동할 수 있게 하는 함수.
  // 이 경우 /cats/som 의 router로 넘어간다.
  console.log("========================================");
  console.log("this is logging middleware for cats/som");
  console.log(req.rawHeaders[1]);
  console.log("========================================");
  next();
});

// 아래와 같은 구조를 router이라고 한다.
app.get("/", (req: express.Request, res: express.Response) => {
  console.log("router");
  res.send({ cats: Cat });
});

app.get("/cats/blue", (req: express.Request, res: express.Response) => {
  console.log("router");
  res.send({ blue: Cat[0] });
});

app.get("/cats/som", (req: express.Request, res: express.Response) => {
  console.log("router");
  res.send({ som: Cat[1] });
});

// middleware을 마지막에 배치하여 유효하지 않은 path에 대해 error를 처리할 수 있다.
app.use((req, res, next) => {
  console.log("========================================");
  console.log("middleware for handling error");
  console.log({ error: "404 not found" });
  console.log("========================================");
});

app.listen(8000, () => {
  console.log("server is On");
});

대부분의 웹 어플리케이션은 client가 application과 상호 작용하는 데 사용할 수 있는 API를 표시한다. 잘 디자인된 API는 아래와 같은 특성을 지원해야 한다.

 

  1. 플랫폼 독립성: 모든 client는 내부의 API가 어떻게 구현되는지 몰라도 API를 호출할 수 있어야 한다. 그러기 위해서는 표준 프로토콜을 사용해야 하고, client 및 web service가 교환할 데이터 형식에 동의할 수 있는 메커니즘이 있어야 한다.
  2. 서비스 진화: Web API는 client 어플리케이션과 독립적으로 기능을 진화시키고 추가할 수 있어야 한다. API가 진화해도 기존 클라이언트 어플리케이션은 수정 없이 계속 작동할 수 있어야 한다. 모든 기능은 client application이 해당 기능을 완전히 이용할 수 있도록 검색이 가능해야 한다.

 

REST는 무엇인가?

2000년에 Roy Fielding이 제안했으며 웹 서비스 디자인을 위한 아키텍처 접근 방식이다. 다음과 같은 특징을 지니고 있다.

  • 하이퍼미디어 기반 분산 시스템을 구축하기 위한 아키텍처 스타일
    • 리소스를 URI로 식별하고. HTTP 메서드(POST, GET, PUT, DELETE)를 사용하여 상태를 전이
  • 기본 프로토콜과 독립적이며, HTTP에 종속되지 않는다.
    • REST는 특정 통신 프로토콜(e.g. HTTP, FTP, SMTP 등)에 의존하지 않는 아키텍처 스타일이다.
    • REST는 HTTP 프로토콜에만 제한되지 않는다. 허나 HTTP는 REST의 가장 일반적인 구현 방법이지만, 필수적인 요소는 아니다.
  • 대부분의 REST API 구현은 HTTP를 사용

HTTP와 REST의 차이점으로, REST는 개방형 표준을 사용하며, 특정 구현에 바인딩 되지 않는다.

  • 개방형 표준은 누구나 접근하고 사용할 수 있는 표준을 의미한다. 따라서 REST API는 다양한 클라이언트에서 호환성을 가질 수 있다.
  • REST는 특정 서버 기술이나 클라이언트 기술에 종속되지 않는다. 즉, RESTful 웹 서비스는 다양한 프로그래밍 언어와 프레임워크로 구현될 수 있다.

 

REST 웹 서비스는 ASP.NET 등 다양한 언어로 작성 가능
클라이언트 어플리케이션은 HTTP 요청 생성 및 응답 구문 분석을 위해 어떤 언어 또는 도구 집합도 사용 가능

 

다음은 HTTP를 사용하는 RESTful API의 몇 가지 기본 디자인 원칙이다.

  • REST API는 리소스를 중심으로 디자인된다. 클라이언트에서 액세스할 수 있는 모든 종류의 개체, 데이터 또는 서비스가 리소스에 포함된다.
  • 리소스마다 해당 리소스를 고유하게 식별하는 URI인 식별자가 있다. 예를 들어 특정 고객 주문 문의의 URI는 다음과 같다.
https://adventure-works.com/orders/1
  • client가 리소스의 표현을 교환하여 서비스와 상호작용한다. 많은 Web API가 교환 형식으로 JSON을 사용한다. 예를 들어 위에 나열된 URI에 대한 GET 요청은 이 응답 본문을 반환할 수 있다.
{"orderId":1,"orderValue":99.90,"productId":1,"quantity":1}
  • 균일한 인터페이스
    • REST API는 균일한 인터페이스를 사용하여 클라이언트와 서버 간의 상호작용을 표준화한다.
    • HTTP를 기반으로 하는 REST API에서는 GET, POST, PUT, PATCH, DELETE와 같은 표준 HTTP 동사를 사용하여 리소스에 작업을 수행한다.
      GET /users/1       -> 사용자 ID 1에 대한 정보 가져오기
      POST /users        -> 새로운 사용자 생성
      PUT /users/1       -> 사용자 ID 1의 전체 정보 업데이트
      PATCH /users/1     -> 사용자 ID 1의 일부 정보 업데이트
      DELETE /users/1    -> 사용자 ID 1 삭제
  • 상태 비저장 요청 모델
    • REST API는 상태 비저장(stateless) 요청 모델을 사용한다.
    • 각 HTTP 요청은 독립적이어야 하며, 요청 간에 일시적인 상태 정보를 유지하지 않는다.
    • 이는 모든 요청이 독립적으로 작동해야 하며, 서버는 클라이언트의 상태를 기억하지 않는다는 것을 의미한다.
    • # 첫 번째 요청 GET /orders/123 # 서버는 이 요청을 처리하며, 클라이언트의 이전 상태를 기억하지 않습니다. # 두 번째 요청 POST /orders # 서버는 이 요청도 독립적으로 처리합니다.
  • 확장성
    • 상태 비저장 모델 덕분에 REST API는 확장성이 뛰어난다.
    • 클라이언트와 특정 서버 간에 선호도를 유지할 필요가 없으므로, 모든 서버가 모든 클라이언트의 요청을 처리할 수 있다.
    • 그러나 백엔드 데이터 저장소의 쓰기 작업 등 다른 요소가 확장성을 제한할 수 있다.
  • 데이터 저장소의 확장 전략
    • 데이터 저장소를 확장하는 전략에는 가로, 세로, 및 기능 데이터 분할이 포함된다.
    • 이러한 전략을 통해 데이터 저장소의 확장성을 높일 수 있다.
    • 1. 가로 분할(Sharding): 데이터를 여러 서버에 분산 저장하여 읽기 및 쓰기 작업을 분산시킨다. 2. 세로 분할(Vertical Partitioning): 데이터 베이스의 테이블을 기능별로 분할하여 각 기능을 독립적으로 확장한다. 3. 기능 데이터 분할(Functional Partitioning): 애플리케이션의 기능별로 데이터를 분할하여 관리한다.
  • REST API는 표현에 포함된 하이퍼미디어 링크에 따라 구동된다. 예를 들어 다음은 order의 JSON 표현을 보여준다. order과 관련된 고객을 가져오거나 업데이트하는 링크를 포함하고 있다.
{
  "orderID":3,
  "productID":2,
  "quantity":4,
  "orderValue":16.60,
  "links": [
    // rel: 현재 리소스와 관련된 다른 리소스에 대한 링크 파일
    //        링크된 리소스와의 관계를 설명한다. 여기서는 product로 제품과 관련된 리소스임을 나타낸다.
    // href: 링크된 리소스의 URI이다. 
    // action: HTTP 메서드를 지정한다.
    {"rel":"product","href":"https://adventure-works.com/customers/3", "action":"GET" },
    {"rel":"product","href":"https://adventure-works.com/customers/3", "action":"PUT" }
  ]
}

 

Leonard Richardson은 Web API의 성숙도를 측정하기 위해 4단계 모델을 제안했다. 이 모델은 API의 RESTful 성격을 평가하는 데 사용된다.

  1. 수준 0: 단일 URI
    • 특징: 모든 작업이 단일 URI에 대한 POST 요청으로 이루어진다.
    • 예시: 모든 요청이 http://example.com/api 에 POST로 전송되어야 하는 경우
      POST /api
      {
      "action": "getUser",
      "userId": 1
      }
  2. 수준 1: 리소스별 URI
    • 특징: 개별 리소스에 대해 별도의 URI를 정의
    • 예시: 사용자. 제품 등 각각의 리소스에 대해 별도의 URI를 사용
      GET /users/1
      GET /products/2
  3. 수준 2: HTTP 메서드
    • 특징: HTTP 메서드(GET, POST, PUT, DELETE)를 사용하여 리소스에 대한 작업을 정의한다.
    • 예시: 각 URI에 대해 적절한 HTTP 메서드를 사용하여 작업을 수행한다.
      GET /users/1       // 사용자 정보 가져오기
      POST /users        // 새 사용자 생성
      PUT /users/1       // 사용자 정보 업데이트
      DELETE /users/1    // 사용자 삭제
  4. 수준 3: 하이퍼미디어(HATEOAS)
    • 특징: 응답에 하이퍼미디어 링크를 포함하여 클라이언트가 다음 작업을 탐색할 수 있게 한다.
    • 예시: 리소스에 대한 작업 링크를 응답에 포함한다.
      {
      "userId": 1,
      "name": "John Doe",
      "links": [
        {"rel": "self", "href": "/users/1"},
        {"rel": "orders", "href": "/users/1/orders"}
       ]
      }



리소스를 중심으로 API 디자인 구성

즉, 웹 API가 표시하는 entity에 집중해야 한다. 예를 들어 전자 상거래 시스템에서 기본 entity는 customer과 order이다.
주문 정보가 포함된 HTTP POST 요청을 전송하여 주문 만들기를 구현할 수 있다. HTTP 응답은 order이 성공적으로 수행되었는지 여부를 나타낸다. 가능하다면 리소스 URI는 동사(리소스에 대한 작업)가 아닌 명사(리소스)를 기반으로 해야 한다

 

https://adventure-works.com/orders // Good

https://adventure-works.com/create-order // Avoid

 

리소스가 단일 실제 데이터 항목을 기반으로 할 필요는 없다. 예를 들어 order 리소스는 내부적으로는 관계형 DB의 여러 table로 구현할 수 있지만, 클라이언트에 대해서는 단일 entity로 표시된다. 단순히 DB의 내부 구조를 반영하는 API를 만들면안된다. REST의 목적은 entity 및 해당 entity에서 애플리케이션이 수행할 수 있는 작업을 모델링하는 것이다. 클라이언트는 내부 구현에 노출되면 안된다.

// 잘못된 설계
GET /orders_table/123
GET /customers_table/456
GET /products_table/789

// 올바른 설계
GET /orders/123

위와 endpoint를 통해 필요한 모든 정보를 얻을 수 있다.

{
  "orderId": 123,
  "orderDate": "2023-06-01",
  "customer": {
    "customerId": 456,
    "name": "John Doe",
    "email": "john.doe@example.com"
  },
  "products": [
    {
      "productId": 789,
      "productName": "Widget",
      "quantity": 2,
      "price": 19.99
    }
  ],
  "total": 39.98
}

 

entitiy는 종종 collection(주문, 고객)으로 그룹화된다. collection은 collection 내 항목과는 별도의 리소스이며 고유한 URI가 있어야 한다.
예를 들어 다음 URI는 주문 컬랙션을 나타낼 수 있다.

 

https://adventure-works.com/orders

 

collection URI에 HTTP GET 요청을 보내면 collection에 있는 항목 목록을 검색한다. 또한 collection의 항목마다 고유의 URI가 있다.
항목의 URI에 대한 HTTP GET 요청은 해당 항목의 세부 정보를 반환한다.

 

URI에 일관적인 명명 규칙을 적용한다. 일반적으로 이렇게 하면 collection을 참조하는 URI에 대해 복수 명사를 사용할 수 있다.
collection 및 항모에 대한 URI를 계층 구조로 구성하는 것이 좋다. 예를 들어 /customers는 고객 collection의 경로이고, /customers/5는 ID가 5인 고객의 경로이다. 이 접근 방식을 사용하면 웹 API를 직관적으로 유지할 수 있다. 또한 많은 Web API 프레임워크는 매개 변수가 있는 URI 경로를 기반으로 요청을 routing할 수 있으므로 개발자는 경로 /customers/{id} 에 대한 경로를 정의할 수 있다.

 

서로 다른 리소스 형식과 이러한 연결을 표시하는 방법 사이의 관계도 고려해야 한다. 예를 들어 /customers/5/orders는 고객 5에 대한 모든 주문을 나타낼 수 있다. 반대 방향으로 이동하여 /orders/99/customer 같은 URI를 사용하여 주문에서 고객으로의 연결을 표시할 수도 있다.
그러나 이 모델을 너무 확장하면 구현이 어려울 수 있다. HTTP 응답 메시지의 본문에 연결된 리소스에 대한 탐색 가능한 링크를 제공하는 방법이 좋다.

 

좀 더 복잡한 시스템에서는 /customers/1/orders/99/products 처럼 클라이언트가 여러 관계 수준을 탐색할 수 있는 URI를 제공하고 싶을 수 있다. 그러나 이 수준의 복잡성은 유지하기 어려울 수 있으며 나중에 리소스 사이의 관계가 변하면 유연성이 떨어진다. 그 대신 URI를 비교적 간단하게 유지해보자. 애플리케이션이 리소스 참조를 지정한 후에는 이 참조를 사용하여 해당 리소스와 관련된 항목을 찾을 수 있어야 한다. 이전 쿼리를 /customers/1/orders URI로 바꿔서 고객 1의 모든 주문을 찾은 후 /orders/99/products로 바꿔서 이 주문의 제품을 찾을 수 있다.

 

팁!
리소스 URI를 컬렉션/항목/컬렉션 보다 더 복잡하게 요구하지 않는 것이 좋다.

'인터넷 기본 지식' 카테고리의 다른 글

#11. CORS, preflight  (0) 2024.06.24
#10. JWT  (0) 2024.06.24
#8. LSP  (2) 2024.06.10
#7. 프로그래밍 패러다임  (1) 2024.06.09
#6. DNS(Domain Name System)  (0) 2024.05.31

+ Recent posts