어디가서 Node.js '개발자'라고 말 하지는 못 하지만 그래도 간단한 프로젝트에서는 언제나 Node.js를 사용해왔다. Node.js 사용자 정도 될 것 같은데, 사용자 입장에서 불편했던 점과 Nestjs + Typescript를 공부하면서 느꼈던 좋은 점을 적어보려 한다. 

 

Node.js + js를 활용하면서 불편했던 점

- 요약: 대부분 높은 자유도에서 오는 피로감.

1. 좋은 프로젝트 구조를 생각해야 한다.
이건 높은 자유도에 따라오는 동전 뒷면같은 부분인데, 프로젝트를 시작시에 어떤 구조로 이 프로젝트 구조를 잡을지 생각해야하고 (시간도 없고 귀찮아지면) 결국 원래 썼던 구조를 따라가게..되는 단점아닌 단점이 있다. 팀원들과 함께 개발을 진행하게 되면 이 구조를 결정한 이유, 어떻게 잡힌 구조인지 설명해야 한다. 

사실 아래처럼 이미 어느정도 자리잡은(?) 좋은 구조가 존재하긴 한다. 

https://velog.io/@hopsprings2/%EA%B2%AC%EA%B3%A0%ED%95%9C-node.js-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%95%84%ED%82%A4%ED%85%8D%EC%B3%90-%EC%84%A4%EA%B3%84%ED%95%98%EA%B8%B0

 

견고한 node.js 프로젝트 설계하기

본 글은 Sam Quinn의 “Bulletproof node.js project architecture” 글을 번역한 것입니다. [Bulletproof node.js project architecture 🛡️ Express.js is great frameworks f

velog.io

 

2. 많은 에러에 대비해야 한다.
간단한 API를 만든다고 생각하면 유저가 보내는 Body나 쿼리에서 최악의 케이스를 생각하고 null값, '', 기대와 다른 타입의 값들을 대비하고 막아주는 처리를 해줘야 하고, 런타임시에 특정 타입에서만 작동하는 메소드를 사용하는 경우 ( ex. .length 등) 에러가 나는 경우도 대비해야 하고.. 많은 것들이 습관화 되면 빠른 개발을 할 수 있지만 더 높은 자유도 때문에 더 신경이 곤두서는 느낌도 든다.

 

Typescript + Nestjs를 배워보면서 좋다고 느낀 점.

- 정해진 틀에서 오는 안정감, 규칙을 지키면 알아서 해주는 많은 기능.

1. 좋은 프로젝트 구조를 알아서 제공해 준다.
도메인 혹은 비즈니스 로직별로 묶어주는 모듈, 모듈내에 있는 API제공 Controller, 로직을 담은 Service, 이렇게 Nestjs에서 제공하는 아키텍처를 따라 개발하면 모듈별로 직관적으로 정리할 수 있다. 서로 다른 모듈끼리 결합시켜서 하나의 큰 로직을 만들 수 있다. E2E나 Unit 테스트를 같이 구성하도록 구조를 함께 잡아주는 것은 덤. 물론 이 구조는 특별한 것이 아니라 견고한 Node.js 프로젝트를 만들기 위한 구조일 수 있지만, 그래도 여러 구조중에 이렇게 써라! 정해주는 느낌이라 더 편하다고 할까..

 

2. 많은 에러를 대비해준다.
유효성 검증에 대한 것들을 DTO와 Validation Pipe등의 기능을 활용하면 정해진 Type에 대한 검증, 빈 값에 대한 검증, 자동 타입 변경등의 많은 역할을 대신해준다. Node.js에서도 여러 패키지를 활용하면 가능할 것 같지만 Nestjs는 이걸 굉장히 자연스럽고 또 쉽게 제공해준다는 느낌을 받았다. 

기타) Status 나 응답 구조를 잡아주는 것도 Node.js의 사용자로서 편하다고 느꼈다. 

 

 

* 입문시 좋은 강의

https://nomadcoders.co/nestjs-fundamentals/lectures/1940

* 읽으면 좋을 참조 글

https://medium.com/daangn/typescript%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%84%9C%EB%B9%84%EC%8A%A4%EA%B0%9C%EB%B0%9C-73877a741dbc

GraphQL의 장점은?

Overfetching, UnderFetching의 문제를 해결할 수 있음.

하나의 endpoint에서 모든 요청을 처리

 

GraphQL은 명세, 형식일 뿐. GraphQL을 구현할 솔루션이 필요하다.

ex. GraphQL.js / GraphQL Yoga 등. 

대표적인 솔루션 - Apollo (서버/프론트) 모두 제공 및 간편하고 쉬운 설정과 다양한 기능.

 

GraphQL의 개념을 공부하면서 처음에 헷갈렸던 부분이 GraphQL이 무엇인지 알겠는데 어떻게 구현하는거지? 라는 생각이었다. 쉽게 생각해보면 하나의 endpoint를 만들어두고 해당 endpoint에서 모든 분기를 공통적으로 처리할 수 있도록 해야하는데, 생각보다 복잡한 구현이 될 것 같았다.

 

역시 GraphQL을 구현해주는 솔루션이 있었는데 Apollo가 대표적인 솔루션 중에 하나이다.

Node.js Apollo서버 예시 코드를 보면서 이해해보자.

* 참고: GraphQL은 5개의 기본 타입이 있는데 ID, String, Int, Float, Boolean이 있다. 열거 타입 enum / 객체타입 type / union타입 : 한 배열안에 다른 타입을 넣고 싶을 때

//db가 있다고 생각해본다.
const seedDB = require('./someseedDB')

//npm install로 apollo server를 설치한다.
const { Apollo, gql } = require('apollo-server')

//일종의 스키마
const schema = gql`
	//Item들 가져오기, Item 단수 하나 가져오기에 해당하는 쿼리 정의
	type Query{
    	getItems : [Item]
        getItem : Item
    }
    //Item 생성, 삭제, 업데이트
    //일반적으로 graphQL에서는 생성, 삭제와 업데이트에 해당하는 것은 Mutation으로 정의한다.
    type Mutation {
    	insertItem(
        	id: Int
            name: String
        	stocks: Int
            price: Int
        ): Item
    	//id에해당하는 Item을 삭제하고 삭제된 item을 반환하는 resolver를 구현한다.
        deleteItem(id: Int): item
        editItem(
        	id: Int
            name: String
            stocks: Int
            price: Int
        ): Item
    }
    type Item {
    	id: Int
        name : String
        stocks: Int
        price : Int
    }
`
//쿼리를 해결해줄 핸들러로 생각하면 이해가 잘 된다.
const resolver = {
	Query: {
    	getItems: () => seedDB.items,
        getItem: (parent, args, context, info) => {},
        editItem: (parent, args, context, info) => {
        	//수정에 관련된 db로직이 들어가면 된다.
        },
        insertItem: (parent, args, context, info) => {
        	//args에 item이 들어올테니 args를 활용해 db에 추가 로직이 들어간다.
        },
        deleteItem: (parent, args, context, info) => {
        	//args를 통해 query나 mutation의 argument를 가져올 수 있다.
        	/*
            	args.id를 활용해 DB에서 삭제로직이 들어갈 것이다.
        	*/
        },
    }
}

//Schema와 resolver를 함께 넣으며 서버를 생성한다.
const server = new ApolloServer({schema, resolver})
server.listen().then(()=>console.log("running at ~~"))

근데 여기까지만 보면 사실 resolver가 그동안 했던 핸들링을 모두 해주는거 아닌가 생각이 든다. 우선 이런형태다.

 

GraphQL을 흔히 REST API와 비교하곤 하는데, 그럼 REST처럼 CRUD 각각 graphQL로 어떤 쿼리를 날려야 하는지 확인 해보고, REST에서 해결하기 힘든 상황을 통해 graphQL의 장점을 확인해보자.

REST getItems에 해당하는 쿼리.

//REST에서 /api/items 로 item전부를 가져오는 쿼리는 graphQL에서는 이렇게 만든다.
query{
  getitems {
          id
          name
          price
          stock
  }
}

 

REST API에서 getItem (단수) 에 해당하는 쿼리.

//id가 1에 해당하는 item을 주세요.
query {
	item(id: 1) {
    	id
        name
        price
        stock
    }
}

 

REST API에서 createItem (addItem)에 해당하는 쿼리(graphQL에서는 Mutation)

mutation {
	//추가할 item의 명세를 인자로 적어 전달한다. 이는 args에 들어갈 것이다.
    //추가하면 resolver를 통해 생성된 item을 받아올 것이기 때문에 뒤에 item을 받아온다.
	insertItem (
    	id: 23939
        name: "nike shoes"
        price: 99900
        stocks: 291
    ) {
		id
        name
        price
        stocks
	}
}

 

REST API에서 updateItem에 해당하는 쿼리(mutation) 위에서 생성한 nike shoes의 stocks를 변경해준다.

mutation {
	//수정할 item의 명세를 인자로 적어 전달한다. 이는 args에 들어갈 것이다.
    //수정하면 resolver를 통해 생성된 item을 받아올 것이기 때문에 뒤에 item을 받아온다.
	editItem (
    	id: 23939
        name: "nike shoes"
        price: 99900
        stocks: 120
    ) {
		id
        name
        price
        stocks
	}
}

 

REST API에서 deleteItem에 해당하는 쿼리

mutation {
	//삭제할 Item의 id를 적어서 보내준다. 삭제한 Item을 리턴값을 받아온다.
	deleteItem (
    	id: 23939
    ) {
	id
        name
        price
        stocks
	}
}

 

여기까지만 보면 graphQL의 장점이 무엇인지 정확히 이해하기 힘들 것이다. 이제 다음 상황을 생각해보자.

 

1. Overfetching 상황

위의 예시에서는 Item이 오직 네개의 필드만을 가지는 간단한 데이터였다. 근데 만일 Item이 엄청나게 많은 필드를 가진 데이터라면 어떤 불편함이 있을까?

type Item struct{
    Name
    Stock
    Stars
    like
    dislike
    Price
    isTrend
    isHot
    wholike
    ...(등등 200개의 필드가 더 있다고 가정해보자)
    created_at
    updated_at
    deleted_at

}

만일 여기서 Item들이 현재 트렌드이고 인기상품인지 알기 위해 (isTrend와 isHot)필드만 알고 싶다고 가정하면, REST에서는 getItems를 통해 전체 필드를 다 가져온 후 확인해야 한다. 결국 필요하지 않은 필드 200여개를 함께 가져오게 되는 것이다. 이게 바로 overfetching이다.

 

그럼 graphQL에서는 어떻게 하면될까?

//이렇게 원하는 필드만 가져옴으로써 overfetching을 해결할 수 있다.
query{
  getitems {
    isHot
    isTrend
  }
}

이렇게 원하는 필드만 가져오면 된다.

 

2. Underfetching 상황

또 다른 예시를 생각해보자. 만일 Item 자원만 필요한 것이 아니라, 구매자들 (Buyer) 자원도 함께 필요하다고 생각해보자. 그럼 REST API를 활용한다면 이런 형태로 활용할 것이다. (Pseudo Code)

//의사 코드로 표현하면 다음과 같을 것이다.

function getItems() {
	return axios.get('/api/items');
}

function getBuyers() {
	return axios.get('/api/buyers');
}

axios.all([getItems(), getBuyers()]).then(....)



이렇게 두 번에 걸쳐서 데이터를 가져오게 된다. 한 번으로는 원하는 값들을 전부 가져오지 못한다 (= undefetching). 이게 underfetching 상황이다.

 

그럼 GraphQL에서는 이를 어떻게 할까? 한번에 쿼리로 원하는값만 딱 가져올 수 있다.

query{
    //심지어 원하는것만 가져올 수 있다.
    getItems{
        id
        isHot
        isTrend
    }
    getBuyers{
        id
        name
        gender
    }
}

 

이정도만 봐도 GraphQL이 어떤 장점이 있는지 캐치하기 쉬울 것이다. 더 다룰 내용이 남았는데 다음에 정리해야 겠다.

 

*다음에 정리할 것

또한 GraphQL은 이름에서부터 알 수 있듯이 여러 자원들간의 연결을 쉽게 해주는 장점이 있다.

이렇게 GraphQL의 장점을 알 수 있는데 이건 기능적인 측면이고, 생산적인 측면에서도 장점을 갖는다.

생산성

기존의 REST방식이라면 다른 API가 필요할 때 백엔드 개발자에게 이를 요청해야 한다. 하지만 GraphQL은 이를 좀 더 유연하게 대처할 수 있도록 해준다. 이는 같은 자원을 요구할 때 서로 다른 플랫폼에서는 다른 필드들만 필요할 수 있는데 이와 같은 상황도 쉽게 대처할 수 있게 해준다.

 

* graphQL한번에 이해하기 쉬운 두개의 영상

참조 1: www.youtube.com/watch?v=1p-s99REAus  

참조 2: www.youtube.com/watch?v=9BIXcXHsj0A

 

카프카 탄생 배경

최근에 MSA도 그렇고 많은 서비스들이 서로 데이터를 주고받아야 할때 위와 같은 상황이 일어난다. 소스가 어디로 데이터를 보내야 하는지가 굉장히 복잡하게 이어져있는데 데이터 복잡성이나 관리 효율적인 측면에서 굉장히 다루기 어려워지고 있었다.

출처 : 카프카, 어떻게 하면 더 효율적으로 사용할까? (고승범님) 유튜브 영상

이를 효율적으로 관리하기 위해서 데이터를 무조건 카프카로 보내고, Destination에서는 카프카에서 데이터를 가져오는 식으로 구성하면 다음과 같이 된다.

출처 : https://www.youtube.com/watch?v=fikIR4eUkM0

 

카프카 용어 및 설명

* 토픽과 파티션

카프카에서 메시지를 저장하는 단위 ( 파일 시스템의 폴더와 유사하다고 볼 수 있다 )

파티션은 메시지를 저장하는 물리적인 파일이며 한 개의 토픽은 한 개 이상의 파티션으로 구성된다.

그럼 어떤 파티션에 프로듀서가 값을 저장할까?

라운드로빈 혹은 키를 활용해서 파티션을 선택 - 같은 키를 활용하면 메시지의 순서를 유지시킬 수 있음을 의미한다.

 

컨슈머는 컨슈머 그룹에 속하고, 한 개의 파티션은 컨슈머 그룹의 하나의 컨슈머만 연결이 가능하다.

(그룹 내부의 다른 컨슈머가 하나의 파티션을 공유하지 못한다)

출처 : https://www.youtube.com/watch?v=geMtm17ofPY

 

 

카프카의 성능이 좋은 이유

파티션 파일은 OS의 페이지 캐시를 사용하고, 파일 IO를 메모리에서 처리한다. 그러므로 서버에서 페이지 캐시를 카프카만 사용해야 성능에 유리하다.

Zero Copy : 디스크 버퍼에서 네트워크 버퍼로 직접 데이터를 복사한다.

컨슈머 추적을 위해서 브로커가 하는일이 단순하다. 메시지 필터링 메시지 재전송등은 프로듀서나 컨슈머가 직접 해야 한다.

묶음처리(batch)를 통해 묶어서 메시지를 전송(프로듀서), 최소 크기만큼 메시지를 모아서 조회(컨슈머).

확장에 용이한 구조를 가지고 있다. 예를들면 브로커나 파티션을 추가하거나, 컨슈머를 추가하여 처리량을 증대시킬 수 있다.

 

리플리카

replication factor 만큼 파티션의 복제본을 가지고 있음.

리더와 팔로워로 구성하여, 프로듀서와 컨슈머는 리더를 통해서만 메시지를 처리한다.

장애 대응 : 리더가 속한 브로커 장애가 생기면 팔로워가 리더가 됨.

 

 

프로듀서

프로듀서의 기본 흐름, 출처 : https://www.youtube.com/watch?v=geMtm17ofPY

여기서 버퍼전까지 넣어주는 Serialize, Partioner부분까지 별도의 스레드

Sender가 별도의 스레드.

이렇게 별도의 스레드로 구성하기 때문에 동시에 동작한다. 

Sender는 linger.ms(전송대기시간) 설정에 따라 브로커로 메시지를 보내는데, 배치가 덜 차더라도 브로커로 바로 전송한다. 

 

- ACK : 0, 1, ALL

Ack=0 : 빠른 전송, 카프카가 받았는지 확인하지 않는다. (0.29ms)

메시지 손실 가능성

Ack=1 : 빠른 전송, 카프카가 받았는지 체크를 한다. (1.05ms)

메시지 손실 가능성 희박하게 있음. (Ack를 받았는데, Ack를 던져주자마자 다운되면 리더가 죽고 다른 팔로워가 리더가 되는데 레플레케이션이 미쳐 되지 않아서 손실될 수 있다)

Ack = All : 느린 전송, 메시지 손실 없음 (2.05ms)

 

출처 : https://www.youtube.com/watch?v=fikIR4eUkM0 

 

결국 buffer memory안에 메시지를 쌓고 Batch size만큼 이동하는데, linger.ms만큼 지연시간을 가지고 send하게된다.

* 기타 전송 재시도 주의 사항

중복 전송 가능, 순서 바뀜 가능 (설정값으로 컨트롤하기)

 

 

 

 

프로듀서

출처 : https://www.youtube.com/watch?v=geMtm17ofPY

 

 

출처 : https://www.youtube.com/watch?v=geMtm17ofPY

 

처음 접근이거나 커밋된 오프셋이 없는 경우

auto.offset.reset 설정을 사용한다. earliest(제일 처음 오프셋), latest(가장 마지막 오프셋), none(컨슈머 그룹에 대한 이전 커밋이 없으면 익셉션 발생)

기타 컨슈머 설정

fetch.min.bytes : 조회시 브로커가 전송할 최소 데이터 크기 (기본 1)

fetch.max.wait.ms : 데이터가 최소 크기가 될 때까지 기다릴 시간 (기본 500)

max.partition.fetch.bytes: 파티션 당 서버가 리턴할 수 있는 최대 크기 (기본 1MB)

 

출처 : https://www.youtube.com/watch?v=fikIR4eUkM0

파티션내부에서는 순서를 보장하지만, 다른 파티션과의 순서는 라운드로빈형태다.

출처 : https://www.youtube.com/watch?v=fikIR4eUkM0

 

기타 카프카 에코시스템

카프카 커넥트

출처 : https://www.youtube.com/watch?v=fikIR4eUkM0

Simple Producer, Consumer.

Producer와 Consumer를 프레임워크 형태로 제공해주는 것.

출처 : https://www.youtube.com/watch?v=fikIR4eUkM0

 

스키마 레지스트리 - 스키마를 저장하고 이를 통해서 통신할 수 있도록함. (가끔 컨슈머에서 맞지 않는 값때문에 오류가 나는 경우가 있는데 이런 것들을 방지)

 

 

기타 디테일한 옵션 부분

log.retention.hours - 어느정도 기간을 보관할 것인지

실제로 diskfull이 일어나는 경우가 많은데, 기본 옵션이 1주일이므로, 생각보다 크게 잡힌다.

delete.topic.enable - 토픽삭제 가능 옵션. diskfull이나면 삭제할 수 있도록.

allow.auto.create.topics - 자동 토픽 생성 옵션.

log.dirs - 초기 옵션은 Temp 폴더에 저장되도록 되어 있는데, Temp는 실제로 임시 저장소의 의미로 OS에 의해서 예기치 않게 삭제될 수 있다.

min.insync.replicas - 프로듀서 ack옵션이 all일 때 저장된 replica의 개수.

DevOps

일반적으로 '개발'이라는 단어가 모두에게 다르게 쓰인다. 

보통 이제야 시작하는 스타트업은 기획 - 설계 - 코딩, 개발 - 배포 - 운영 등의 일련의 긴 과정에서 기획 후의 모든 부분을 하는 것을 (슈퍼풀스택)개발자로 퉁 치고 운영한다. 사실 기획도 결국 같이 하게 된다.

규모가 있는 서비스가 되면 점점 복잡해 지기 때문에 인프라 담당자가 따로 있고 (설계)

갖춰진 인프라 위에 개발하는 개발자가 (프론트 / 백엔드 등등..으로 나뉜다)

또 여러명이서 개발을 하게 되면 협업을 위해 협업툴 활용 (git, gitlab...)을 하면서 서로의 소스를 지속적으로 통합(CI)해야 하고

여러 테스트를 거쳐야 하고

또 해당 프로젝트를 빌드해야 하고

이를 빠른 주기로 사용자들에게 배포를 해줘서 애자일하게 개발을 해야 한다.

또 서비스가 잘 운영되고 있는지, 서버는 잘 돌아가고 있는지 모니터링 해주고 적절하게 대응도 해 줘야 한다.

 

 이렇게 많은 일들을 일반적으로 개발 / 운영 으로 나눴었는데 이렇게 두개를 나누면서 생기는 문제점들이 많았다. 예를들면 개발자들은 요건사항에 맞춰서 개발만하고 이를 운영쪽으로 넘겨버리는데, 사실 요구조건은 처음부터 완벽한 것이 아니라 사용자들의 여러 피드백을 받으면서 완벽해 지기 때문이다.

 그래서 이를 나누지말고 협업을 중시하는 하나의 팀을 만들며 문화를 바꾸자! 라고 얘기하는 것이 Dev(개발)+Ops(운영) DevOps이다. (라고 이해했다 사실 추상적인 개념인 것 같다)

 

 DevOps가 추상적인 개념이라면 그래도 구체적으로 함께 따라오는 특징, 키워드들이 있다.

영국정부에서 제공하는 "Good Habit for Devops"의 내용이다.

Cross Functional Team - 개발~배포,테스트를 할 수 있는 역량을 팀내에 채워라.

Widely Shared Metrics - 서비스 현황을 파악할 수 있는 지표를 만들어라

Automating repetitive tasks - CI/CD를 이용하여 빌드배포테스팅 프로세스를 자동화해라

PostMortems - 팀원들과 장애를 함께 공유해라

Regular Release - 짧은 주기로 배포하면서 VOC를 반영해라

 

결국 DevOps란 Dev + Ops로 서비스를 개발에서 부터 운영까지 할 수 있게 노력하는 문화라고 할 수 있다.

 

CI (Continuous Integration)

여러명의 개발자가 하나의 프로젝트에 함께하면 코드 충돌이 일어나기도 하고, 서로 영향을 줄 수 있는데 이를 지속적으로 통합하고 빌드하고 테스트 하는 과정이다. (사실 CI라고 하면 이를 지속적으로 통합하는 과정이기도 하지만 그 과정을 자동화 시키는 것을 얘기한다)

 

CD(Continuous Delivery/Deployment)

여러 개발자들의 코드 변경사항을 버그 테스트를 거쳐 저장소에 업로드 되고, 이를 프로덕션 환경으로 릴리스 하는 것이다.

 

 

 

 

참고 : bcho.tistory.com/817

 

개발과 운영의 조화 - Devops #2/2

1편 글 링크 - http://bcho.tistory.com/815 Devops의 정의  이러한 개념들을 적극적으로 적용한 기업들이 Netflix, Flicker와 같은 인터넷 서비스 기업이다. 기존 개발 프로세스에 비해서 훨씬 빠르게 고객의

bcho.tistory.com

 

1. 웹서버 - 클라이언트의 요청에 의해 정적 파일을 제공하는 서버. Apache, NginX, IIS등의 종류가 있으며, 정적파일만 제공할 수 있다.

2. WAS(Web Application Server) - 웹 서버와 웹 컨테이너를 포함한 개념. 웹 서버에서는 정적 파일만 제공하므로, 동적으로 DB와 연결하여 여러 데이터를 활용해 Php, Jsp, Asp로 표현된 페이지를 해석할 수 있어야 하는데 이 역할을 웹 컨테이너가 제공한다.

 

왜 웹 서버와 WAS를 분리했을까?

- 두개를 분리하면 웹 서버를 한번 거치기에 보안상의 이점 존재. 또한 웹 서버에서 고장난 WAS로 보내지 않고, 그 시간동안 이를 복구시킨다면 사용자는 끊김없이 서버에서 서비스를 제공받을 수 있다. (=fail over, fail back). 톰캣에서는 웹서버와 WAS의 역할을 모두 하기도 한다.

 

Node.js는 Apache, 톰캣과 같은 개념과 무엇이 다른가?

 Node.js 는 정적 파일 제공과 WAS기능(=언어 해석) 모두 담당한다. Express.js를 통해 정적 파일을 제공하고 나머지가 언어해석, DB연결등을 담당하는 큰 그림으로 볼 수 있지만 나누는 것이 크게의미가 있나 싶다. 그렇다고 Node.js를 사용하면 NginX와 같은 웹 서버를 사용하지 않는 것은 아니다. 기업에서는 NginX나 Apache등의 그동안 쌓인 기술들을 활용하여 이를 리버스 프록시 서버로 활용하여 보안상의 이점과 캐싱등을 통해 속도상의 이점을 갖는다.

+ Recent posts