프라이빗 클라우드(Private cloud)는 정부 표준 기구와 민간 클라우드 업계가 모두 동의하는, 명확하게 정의된 용어다. 프라이빗 클라우드 사용이 줄어들고 있다는 의견도 있지만 최근 분석 결과를 보면, 기업의 프라이빗 클라우드 투자는 여전히 맹렬한 속도로 증가 중이다.

Credit: Getty Images Bank

IDC는 한 연구에서 2018년 2분기 프라이빗 클라우드 시장 규모는 2017년 같은 기간에 비해 28.2% 증가한 46억 달러를 돌파할 것으로 내다봤다. 기업 조직이 이처럼 프라이빗 클라우드에 몰려드는 이유는 무엇일까.

프라이빗 클라우드란 무엇인가
미국 국립표준기술연구소(National Institute for Standards and Technology, NIST)에 따르면, 클라우드에는 퍼블릭, 커뮤니티, 하이브리드, 프라이빗의 네 가지 유형이 있다.

NIST는 나머지 클라우드와 구분되는 프라이빗 클라우드의 뚜렷한 특징을 다음과 같이 설명했다. "클라우드 인프라는 복수의 소비자(예: 사업부)로 구성된 단일 조직에 의해 독점적으로 사용되도록 프로비저닝된다. 이 인프라는 해당 조직, 서드파티 또는 이 둘의 조합이 소유, 관리 및 운영할 수 있으며 회사 내부 또는 외부에 위치할 수 있다."

이것이 NIST가 정의하는 프라이빗 클라우드의 특징이지만 다른 클라우드와 공유하는 5가지 공통점도 있다.

첫째, 온디맨드 셀프 서비스(on-demand self-service)다. 이는 최종 사용자가 IT에 도움을 요청하지 않고 직접 컴퓨팅 리소스를 프로비저닝할 수 있음을 의미한다.

둘째는 폭넓은 액세스(broad access)다. 워크스테이션부터 노트톱, 태블릿, 스마트폰에 이르기까지 모든 유형의 디바이스를 통해 클라우드의 리소스에 액세스할 수 있어야 한다.

셋째, 리소스 풀링(resource pooling)이다. 리소스 풀링은 컴퓨팅 리소스 사용의 전반적인 효율성을 높여주는데, 이는 다양한 테넌트가 동적으로 할당되는 리소스를 공유함을 의미한다. 프라이빗 클라우드에서는 조직의 여러 사업부가 리소스를 공유하는 것을 의미하지만 어쨌든 해당 조직만 독점적으로 리소스를 사용한다. 멀티 테넌시 서비스와 달리 다른 회사와 리소스를 공유하지 않는다.

넷째는 탄력성(rapid elasticity)이다. 이를 통해 필요에 따라 용량을 늘리거나 줄이고 필요한 다른 부서에서 사용할 수 있도록 리소스를 풀 수 있다.

마지막 다섯 번째는 측정(measured service)이다. 공급자와 사용자는 다양한 리소스(스토리지, 프로세싱, 대역폭, 사용자 계정 수 등)를 얼마나 사용하는지 측정해 리소스를 최적으로 할당할 수 있다.

시각화는 프라이빗 클라우드의 일부분일 뿐
서버에 하이퍼바이저를 올려 시각화(virtualization)를 활용한다고 해서 프라이빗 클라우드 컴퓨팅이 되는 것은 아니다. 시각화는 클라우드 컴퓨팅의 중요 구성 요소지만 그 자체가 클라우드는 아니다.

시각화 기술은 조직에서 리소스를 풀링하고 할당할 수 있게 해준다. 이는 두 가지 모두 NIST의 클라우드 정의에 포함된다. 그러나 기술적으로 클라우드 환경으로 간주되기 위해서는 셀프 서비스 및 리소스 확장 기능과 관련된 다른 특성이 필요하다.

퍼블릭 또는 하이브리드 클라우드와 비교할 때 프라이빗 클라우드는 구체적으로 단일 조직에 의해 사용되는 리소스, 또는 조직의 클라우드 기반 리소스가 완전히 격리되어 있는 상태를 가리킨다.

프라이빗 클라우드의 경제학
프라이빗 클라우드에 관한 가장 큰 오해 중 하나는 비용 절감이다. 물론 비용을 절감할 수 있고 실제로 절감하는 경우도 자주 있지만 프라이빗 클라우드 자체가 비용 절감에 유리한 것은 아니다.

우선 경우에 따라 초기 비용이 상당히 크다. 예를 들어 프라이빗 클라우드 네트워크의 중요한 부분인 자동화 기술을 구현하는 데는 대다수 IT 조직이 감당하기 어려운 막대한 비용 투자가 필요할 수 있다. 자동화 기술을 구현하면 리소스를 더 효율적으로 재할당하고 신규 하드웨어를 위한 설비 투자를 줄여 비용 절감을 할 수도 있다. 그러나 전체적인 비용 절감이 보장되지는 않는다.

가트너 분석가들은 프라이빗 클라우드 모델을 도입하는 주된 목적은 비용 절감이 아니라 민첩성과 동적 확장성의 향상이어야 한다고 말했다. 이를 통해 프라이빗 클라우드 기술을 활용하는 기업에서는 제품 출시 시간을 단축할 수 있다.

프라이빗 클라우드는 퍼블릭 클라우드에 구현 가능
많은 사람은 프라이빗 클라우드라고 하면 무조건 조직의 프라이빗, 온프레미스 데이터센터에 위치하고 퍼블릭 클라우드는 외부 서비스 제공업체로부터 받는 것이라고 생각한다. 그러나 NIST에 따르면, 프라이빗 클라우드는 프라이빗 조직에 의해 소유, 관리 및 운영될 수 있지만 그 인프라는 조직 외부에 위치할 수 있다.

많은 업체가 오프프라미스(off-premise) 프라이빗 클라우드를 제공한다. 이는 물리적 리소스가 외부 시설에 위치하면서 단일 고객 전용으로 제공될 수 있음을 의미한다. 여러 고객 사이에서 다중 테넌트로 리소스를 풀링하는 퍼블릭 클라우드와 달리 이런 리소스는 공유되지 않는다. 가트너 분석가 톰 비트맨은 "프라이빗 클라우드 컴퓨팅을 규정하는 요소는 위치나 소유권 또는 관리 책임이 아니라 프라이버시"라고 말했다.

클라우드 제공업체와 거래할 때는 보안의 명확성에 주의를 기울여야 한다. 예를 들어 일부 공급업체는 데이터센터 운영을 각 고객별로 전용 하드웨어를 배치할 수 없는 코로케이션 시설로 아웃소싱한다. 또는 여러 고객을 대상으로 리소스를 풀링하면서 VPN을 사용해 분리함으로써 프라이버시를 보장하다고 주장하기도 한다. 비트맨은 오프프라미스 프라이빗 클라우드의 경우 세세한 부분까지 잘 확인해야 한다고 조언했다.

프라이빗 클라우드는 IaaS 이상
서비스형 인프라(Infrastructure as a service, IaaS)는 프라이빗 클라우드 아키텍처를 도입하는 주요 이유지만 프라이빗 클라우드의 유용성이 서비스형 인프라에만 있는 것은 결코 아니다. 비트맨은 IaaS가 가장 빠르게 성장하는 분야라고 말했지만 서비스형 소프트웨어와 플랫폼 역시 중요하다.

비트맨은 "IaaS는 가장 낮은 수준의 데이터센터 리소스를 소비하기 쉬운 방식으로 제공할 뿐이며 IT의 운영 방식을 근본적으로 바꾸지는 않는다"고 말했다. 서비스형 플랫폼(PaaS)에서는 조직이 클라우드 인프라에서 실행되도록 맞춤형 애플리케이션을 만들 수 있다. PaaS에도 퍼블릭 또는 프라이빗 형태가 있다. 각 형태에 따라 애플리케이션 개발 서비스가 온프레미스 데이터센터에 호스팅되거나 업체가 제공하는 전용 환경에 호스팅된다.

프라이빗 클라우드가 항상 프라이빗은 아니다
프라이빗 클라우드는 많은 조직에서 클라우드 네트워크를 향한 자연스러운 첫 단계다. 퍼블릭 클라우드와 관련된 보안에 대한 우려 없이(그 우려에 실체가 있든 없든) 민첩성, 확장성, 효율성과 같은 클라우드의 이점을 제공하기 때문이다. 그러나 비트맨은 클라우드 시장이 계속 발전하면서 기업 조직도 퍼블릭 클라우드 리소스를 자연스럽게 받아들이게 될 것으로 예상했다. 앞으로 서비스 수준 협약과 보안 수단은 성숙해지고 가동 중단과 다운타임의 영향은 최소화된다.

가트너는 프라이빗 클라우드 환경이 결국 대부분 하이브리드 클라우드로 변형될 것으로 예상했다. 이는 퍼블릭 클라우드 리소스를 활용함에 따라 지금의 프라이빗 클라우드가 미래에는 하이브리드 클라우드가 될 수 있음을 의미한다.

비트맨은 "프라이빗 클라우드로 시작하면서 IT 부서는 프라이빗, 퍼블릭, 하이브리드 또는 전통적인 형태를 불문하고 기업을 위한 모든 서비스의 중개자 역할을 하게 된다"면서, "프라이빗 클라우드는 하이브리드, 또는 퍼블릭 클라우드로 진화하면서 셀프 서비스의 소유권을 유지할 수 있으며 따라서 고객과 인터페이스를 유지할 수 있다. 이것이 가트너에서 '하이브리드 IT'로 지칭하는 IT의 미래 비전 중 일부다"고 말했다.

클라우드 송환
기업에서 워크로드와 리소스를 퍼블릭 클라우드로 옮긴 다음 다시 프라이빗 클라우드 또는 비 클라우드 환경으로 되돌리는 경우를 두고 클라우드 송환(cloud repatriation)이라고 한다.

451 리서치(451 Research)의 2017년 설문에 따르면, 응답자의 39%는 최소한 일부 데이터 또는 애플리케이션을 퍼블릭 클라우드에서 다른 곳으로 옮겼다고 답했으며 가장 큰 이유는 성능과 가용성 문제였다. 451 리서치는 이 연구에 관한 블로그 글에서 "응답자가 선택한 이유 가운데 상당수는 기업이 애초에 퍼블릭 클라우드로 전환하기로 결정한 이유와 동일하다"고 전했다.

설문 응답자들이 선택한 상위 5개 이유는 성능/가용성 문제(19%), 온프레미스 클라우드의 개선(11%), 데이터 주권 관련 규제의 변화(11%), 예상보다 높은 비용(10%), 지연 문제(8%), 보안 침해(8%)다.

이 설문 결과가 IT 의사 결정자들이 퍼블릭 클라우드를 버리고 프라이빗 클라우드를 선택했음을 보여주는 것은 아니다. 이보다는 각 조직에서 클라우드 환경은 지속적으로 발전하며, 많은 조직이 프라이빗과 퍼블릭 클라우드를 모두 포용하는 하이브리드 클라우드를 구축하고 있음을 나타낸다. 451 설문 응답자의 과반수(58%)는 "온프레미스 시스템과 오프프레미스 클라우드/호스팅되는 리소스를 통합된 방식으로 모두 활용하는 하이브리드 IT 환경을 추진 중"이라고 답했다. editor@itworld.co.kr 





원문보기: 
http://www.itworld.co.kr/news/111148#csidx6b12067c3fd04ea90c6debaca549176 

스프링은 기본적으로 IoC와 DI를 위한 컨테이너로서 동작하지만 그렇다고 "스프링은 단지 IoC/DI 프레임워크다"라고는 말할 수 없습니다. 스프링은 단순히 IoC/DI를 편하게 적용하도록 돕는 단계를 넘어서 엔터프라이즈 애플리케이션 개발의 전 영역에 걸쳐 다양한 종류의 기술에 관여합니다.


그렇다면 과연 스프링이란 무엇이고 어떻게 설명할 수 있을까요? 스프링 프레임워크가 만들어진 이유와 존재 목적, 추구하는 가치는 무엇일까요? 스프링의 사상과 가치, 그리고 적용된 원칙을 깊이 있게 생각하는 과정을 통하면 스프링이란 도대체 무엇이고 왜 존재하는지를 좀 더 체계적으로 이해할 수 있다면 앞으로 스프링을 더 쉽게 이해하는 데 도움이 될 것입니다.



1. 스프링의 정의


스프링이란 이런 것이다라고 한마디로 정의하기는 쉽지 않습니다. 스프링은 간단한 몇 단어로 규정하기에는 쉽지 않은 독특한 특징이 있기 때문입니다. 게다가 스프링에 대한 여러가지 정의를 본다고 해서 스프링이 무엇인지 간단히 이해되는 것도 아닙니다. 그렇다고 스프링을 그때그때 필요한 API 사용 방법 위주로만 공부하면 스프링을 오해하거나 그 가치를 충분히 누리지 못할 수 있습니다.


그래서 한번쯤은 스프링의 정의를 통해 스프링이 어떤 것인지 큰 그림으로 이해해보려고 노력할 필요가 있습니다. 정의란 원래 사물의 본질적인 뜻을 담고 있습니다. 따라서 정의를 이해하려는 노력은 스프링을 깊이 이해하고 그 가치를 파악하는 데 도움이 될 것입니다. 또, 스프링의 정의 하나쯤은 기억해두면 유용합니다. 스프링을 잘 모르는 고객이나 상사가 어느 날 "스프링이 도대체 뭐야?"라고 질문했는데 간단명료하게 대답을 못하는 것도 곤란할 테니까 말입니다.


스프링에 대해 가장 잘 알려진 정의는 아래와 같습니다.


자바 엔터프라이즈 개발을 편하게 해주는 오픈소스 경량급 애플리케이션 프레임워크


정의를 봐도 스프링이 무엇인지 감이 바로 오지는 않을 것입니다. 하지만 이 정의에는 스프링의 중요한 특징이 잘 담겨 있습니다.



1-1. 애플리케이션 프레임워크

일반적으로 라이브러리나 프레임워크는 특정 업무 분야나 한 가지 기술에 특화된 목표를 가지고 만들어집니다. 예를 들면 웹 계층을 MVC 구조로 손쉽게 만들 수 있게 한다거나, 포맷과 출력장치를 유연하게 변경할 수 있는 애플리케이션 로그 기능을 제공한다거나, 간단한 설정만으로 관계형 DB와 자바오브젝트를 매핑해주는 ORM 기술을 제공하는 것들입니다. 그래서 프레임워크는 애플리케이션의 특정 계층에서 주로 동작하는 한가지 기술 분야에 집중됩니다. 하지만 스프링은 이와 다르게 '애플리케이션 프레임워크'라는 특징을 갖고 있습니다.


애플리케이션 프레임워크는 특정 계층이나, 기술, 업무 분야에 국한되지 않고 애츨리케이션의 전 영역을 포괄하는 범용적인 프레임워크를 말합니다. 애플리케이션 프레임워크는 애플리케이션 개발의 전 과정을 빠르고 편리하며 효율적으로 진행하는데 일차적인 목표를 두는 프레임워크입니다.


스프링이 자바 엔터프라이즈 개발의 전 영역을 포괄하는 애플리케이션 프레임워크가 된 데는 스프링의 탄생배경과 밀접한 관련이 있습니다. 스프링은 처음부터 독자적인 프레임워크로 개발된 것이 아닙니다. 재미있게도 스프링의 기원은 J2EE 기술서적에 딸린 예제 코드입니다. 로드 존슨은 2003년에 [Expert One-on-One J2EE Design and Development]라는 책을 출간했습니다. 자바 엔터프라이즈 개발에 관한 자신의 풍부한 경험을 바탕으로 J2EE 애플리케이션 설계와 개발의 모든 영역에 대한 개발 전략을 다룬 책입니다. 이 책에 소개된 독창적인 개발 전략과 기존 기술에 대한 대안은 설명으로만 그치지 않고, 그 개념을 증명할 수 있도록 만들어진 3만 라인가량의 샘플 애플리케이션 형태로 제공됐습니다. 이 책에서 강조한 중요한 전략의 하나는 "항상 프레임워크 기반으로 접근하라"는 것이었습니다. 당연히 책의 예제 애플리케이션도 프레임워크를 먼저 만들고 나서, 프레임워크를 이용하는 코드를 만드는 방식으로 작성됐습니다. 바로 이 예제에 포함된 프레임워크가 스프링 프레임워크의 기원입니다. 이 책에서 주장하는 자바 엔터프라이즈 개발의 이상적인 프로그래밍 모델을 추구하는 데 필요한 기반이 돼주는 코드, 즉 프레임워크가 지금 스프링의 원시 버전이라고 보면 됩니다.


J2EE Design and Development PDF


이 책의 내용과 예제로 제공된 프레임워크에 매료된 개발자들이, 책의 독자들이 토론하는 출판사 포럼에 모이기 시작했습니다. 그리고 그중 의욕 있는 일부 개발자는 책에 나오는 프레임워크를 단지 예제 수준으로 두기에는 아깝다는 생각을 했고, 그것을 발전시켜서 지속적으로 개발하자는 의견을 냈습니다. 그런 열의를 가진 몇몇 개발자와 그들에게 설득당한 저자인 로드 존슨도 참여하면서 정식으로 스프링 프레임워크라는 이름의 오픈소스 프로젝트가 시작돼서 오늘날에 이르게 된 것입니다.


스프링의 기원이 된 예제 애플리케이션의 프레임워크는 책에서 설명한 각종 자바 엔터프라이즈 개발 전략의 핵심을 담아서 개발했습니다. 이 책 자체가 자바 엔터프라이즈 개발의 전 계층에 등장하는 기술과 애플리케이션의 전 영역에 대한 효과적인 설계와 개발 기법을 다루고 있었기 때문에 예제 프레임워크 또한 애플리케이션 전반에 걸친 모든 분야를 포괄하고 있었습니다. 결과적으로 이 예제 프레임워크로부터 시작된 스프링은 자연스럽게 애플리케이션의 전 영역을 지원하는 종합적인 애플리케이션 프레임워크가 된 것입니다.


단지 여러 계층의 다양한 기술을 그저 한데 모아뒀기 때문에 애플리케이션 프레임워크라고 불리는 건 아닙니다. 애플리케이션의 전 영역을 관통하는 일관된 프로그래밍 모델과 핵심 기술을 바탕으로 해서 각 분야의 특성에 맞는 필요를 채워주고 있기 때문에, 애플리케이션을 빠르고 효과적으로 개발할 수가 있습니다. 바로 이것이 스프링이 애플리케이션 프레임워크라고 불리는 이유입니다.


스프링 MVC 프레임워크 또는 JDBC/ORM 지원 프레임워크라고 생각하는 것은 스프링이 다루는 일부 영역만 봤기 때문입니다. 또, 스프링을 IoC/DI 프레임워크나 AOP 툴이라고 보는 이유는 스프링이 제공하는 핵심 기술에만 주목했기 때문입니다. 스프링의 일차적인 존재 목적은 핵심 기술에 담긴 프로그래밍 모델을 일관되게 적용해서 엔터프라이즈 애플리케이션 전 계층과 전 영역에 전략과 기능을 제공해줌으로써 애플리케이션을 편리하게 개발하게 해주는 애플리케이션 프레임워크로 사용되는 것임을 기억해 두는 것이 좋습니다.



1-2. 경량급

스프링 정의의 다음 항목은 경량급(lightweight)입니다. 스프링이 경량급이라는 건 스프링 자체가 아주 가볍다거나 작은 규모의 코드로 이뤄졌다는 뜻은 아닙니다. 오히려 스프링은 20여 개의 모듈로 세분화되고 수십만 라인에 달하는 코드를 가진 매우 복잡하고 방대한 규모의 프레임워크입니다.


그럼에도 스프링이 가볍다고 하는 이유는 무엇일까요? 그것은 불필요하게 무겁지 않다는 의미입니다. 아는 스프링의 기원이 된 책에서 비판하는 자바 엔터프라이즈 기술의 불필요한 복잡함에 반대되는 개념입니다. 특히 스프링이 처음 등장하던 시절의 자바 주류 기술이었던 예전의 EJB 같은 과도한 엔지니어링이 적용된 기술과 스프링을 대비시켜 설명하려고 사용됐던 표현입니다.


당시 EJB는 기술에 대한 과도한 욕심으로 인해 개발환경과 운용서버, 개발과 빌드, 테스트 과정, 작성된 코드 모두를 매우 무겁고 복잡하게 만들었습니다. EJB가 동작하려면 고가의 느리고 무거운 자바 서버(WAS)가 필요했습니다. 또한 툴의 도움 없이는 다루기 힘든 난해한 설정파일 구조와 까다로운 패키징, 불편한 서버 배치(deploy) 등으로 인한 부담 때문에 고가의 제품으로 구성된 제대로 된 개발환경을 갖추지 않고는 개발하기가 힘들었습니다.


그에 반해 스프링은 가장 단순한 서버환경인 톰캣(Tomcat)이나 제티(Jetty)에서도 완벽하게 동작합니다. 단순한 개발툴과 기본적인 개발환경으로도 엔터프라이즈 개발에서 필요로 하는 주요한 기능을 갖춘 애플리케이션을 개발하기에 충분합니다. 서블릿 컨테이너만으로 충분하니 EJB 컨테이너를 비롯해 복잡한 기능이 잔뜩 포함된 고급 WAS를 굳이 사용하지 않아도 됩니다. 그만큼 개발 과정도 단순해집니다. 스프링의 장점은 그런 가볍고 단순한 환경에서도 복잡한 EJB와 고가의 WAS를 갖춰야만 가능했던 엔터프라이즈 개발의 고급 기술을 대부분 사용할 수 있다는 점입니다. 코드는 더 단순하고 개발 과정은 편리하면서도 EJB에서조차 불편했던 고급 기능을 세련된 방식으로 적용할 수 있습니다.


결과적으로 스프링은 EJB를 대표로 하는 기존의 많은 기술이 불필요하게 무겁고 복잡했음을 증명한 셈이고, 그런 면에서 스프링은 군더더기 없이 깔끔한 기술을 가진 '경량급' 프레임워크라고 불린 것입니다.


스프링의 이런 특징은 개발환경과 서버에만 국한된 게 아닙니다. 경량급이라는 의미는 스프링을 기반으로 제작되는 코드가 기존 EJB나 여타 프레임워크에서 동작하기 위해 만들어진 코드에 비해 상대적으로 작고 단순하다는 뜻이기도 합니다. 같은 기능을 수행하는 코드인데도 스프링 기반의 코드가 가벼운 이유는 코드에 불필요하게 등장하던, 프레임워크와 서버환경에 의존적인 부분을 제거해주기 때문입니다. EJB와 WAS 같은 기술과 환경을 지원하기 위해 군더더기처럼 우겨넣어야 했던, 판에 박힌 듯이 반복되던 코드가 제거되고 나니 가장 단순하고 가벼운 코드만 남게 됐습니다.


다시 말하자면 스프링이 가볍다는 건 기술수준이 가볍다거나, 스프링이 유치하고 용도가 제한적이라는 의미는 결코 아닙니다. 고성능이면서 내구성도 좋은 스포츠카가 그저 덩치만 크고 성능은 떨어지는 차에 비해 오히려 중량은 가볍고 차체도 작다는 것과 마찬가지 개념이라고 생각해도 좋을 것입니다. 만들어진 코드가 지원하는 기술수준은 비슷하더라도 그것을 훨씬 빠르고 간편하게 작성하게 해줌으로써 생산성과 품질 면에서 유리하다는 것이 바로 경량급이라는 말로 표현되는 스프링의 특징입니다.



1-3. 자바 엔터프라이즈 개발을 편하게

이번에 살펴볼 정의 내용은 '자바 엔터프라이즈 개발을 편하게 해주는'입니다. 스프링 뿐 아니라 기존에 등장했던 대부분의 자바 엔터프라이즈 기술과 프레임워크는 저마다 '개발을 편하게 해준다'고 주장하고 있습니다. 하지만 스프링이 말하는 '엔터프라이즈 개발을 편하게'라는 말은 그 무게가 다릅니다. 스프링은 근본적인 부분에서 엔터프라이즈 개발의 복잡함을 제거해내고 진정으로 개발을 편하게 해주는 해결책을 제시합니다. 단순히 편리한 몇 가지 도구나 기능을 제공해주는 차원이 아닙니다. 엔터프라이즈 개발의 근본적인 문제점에 도전해서 해결책을 제시한다는 것이 기존 기술의 접근 방법과 스프링의 접근 방법의 차이점입니다.


흥미롭게도 이 문구는 EJB가 처음 등장했을 때도 사용됐습니다. 엔터프라이즈 개발을 위한 본격적인 자바 기술로 세상에 처음 등장했던 EJB 버전 1.0의 스펙문서를 살펴보면 EJB의 목표를 다음과 같이 이야기하고 있습니다.


EJB를 사용하면 애플리케이션 작성을 편하게 할 수 있다. 로우레벨의 트랜잭션이나 상태 관리, 멀티스레딩, 리소스 풀링과 같은 복잡한 로우레벨의 API 따위를 이해하지 못하더라도 아무런 문제 없이 애플리케이션을 개발할 수 있다.

 - Enterprise JavaBeans 1.0 Specification, Chapter 2 Goals


이 목표에서 볼 수 있듯이 편리한 애플리케이션 개발이란 개발자가 복잡하고 실수하기 쉬운 로우레벨 기술에 많은 신경을 쓰지 않으면서도 애플리케이션의 핵심인 사용자의 요구사항, 즉 비즈니스 로직을 빠르고 효과적으로 구현하는 것을 말합니다.


EJB의 비전과 목표는 바로 이것이었습니다. EJB의 약속대로 일정 부분에서는 엔터프라이즈 개발의 고민거리와 부담을 덜어줬습니다. 문제는 이 과정에서 다른 차원의 더 큰 복잡함을 애플리케이션 개발에 끌고 들어오는 실수를 저질렀다는 점입니다. 이 때문에 거의 대부분의 EJB 개발자는 처음 기대와는 달리 이전보다 더 어렵고 불편해진 애플리케이션 개발에 지쳐갔고, 결국 EJB의 접근 방법은 잘못됐음을 깨닫고 다른 대안을 찾게 됐습니다. 스프링도 당시 EJB의 잘못된 접근 방법에 대한 대안을 모색하는 중에 등장한 것입니다.


따라서 스프링은 EJB가 궁극적으로 이루고자 했던 이 목적을 제대로 실현하게 해주는 프레임워크입니다. 스프링은 애플리케이션 개발자들이 스프링이라는 프레임워크가 제공하는 기술이 아니라 자신이 작성하는 애플리케이션의 로직에 더 많은 관심과 시간을 쏟게 해줍니다. 초기에 스프링의 기본 설정과 적용 기술만 잘 선택하고 준비해두면, 이후로 애플리케이션 개발 중에는 스프링과 관련된 코드나 API에 대해 개발자가 거의 신경 쓸 일이 없습니다. 스프링이 '엔터프라이즈 개발을 편하게 해준다'라는 EJB와 동일한 목적을 추구하지만 그 과정에서 다른 불편함을 추가하지 않아도 되게 만들었기 때문에 가능한 일입니다.


스프링은 또한 엔터프라이즈 개발의 기술적인 복잡함과 그에 따른 수고를 제거해줍니다. 여기서 제거한다는 건 그런 기술적인 필요를 무시한다는 의미는 아닙니다. 엔터프라이즈 개발에서 필연적으로 요구되는 기술적인 요구를 충족하면서도 개발을 복잡하게 만들지 않는다는 점이 스프링의 뛰어난 면입니다. 과연 어떻게 해서 스프링이 개발을 편하게 만들어주는지는 뒤에서 계속 이야기 하겠습니다.



1-4. 오픈소스

스프링은 오픈소스 프로젝트 방식으로 개발돼왔습니다. 지금도 여전히 오픈소스 개발 모델과 오픈소스 라이선스를 가지고 개발되는 중이며, 이 사실은 앞으로도 바뀌지 않을 것입니다.


오픈소스란 말 그대로 소스가 모두에게 공개되고, 특별한 라이선스를 취득할 필요없이 얼마든지 가져다 자유롭게 이용해도 된다는 뜻입니다. 소스를 자유롭게 열람하고 자신의 목적에 맞게 사용할 수 있을 뿐만 아니라, 필요하면 맘대로 수정할 수 있고, 수정된 제품과 소스를 다시 공개적으로 배포하는 자유도 허용됩니다. 물론 오픈소스도 저작권이 있기 때문에 원 저작자에 대한 정보와 라이선스는 유지한 채로 사용하거나 배포해야지, 자신이 만든 것처럼 슬며시 가져다 사용해도 좋다는 뜻은 아닙니다.


스프링에 적용된 오픈소스 라이선스는 오픈소스 라이선스 중에서도 비교적 제약이 적고 사용이 매우 자유로운 편인 아파치 라이선스 버전 2.0(Apache)입니다. 아파치 라이선스에 따르면 스프링을 상업적인 목적의 제품에 포함시키거나 비공개 프로젝트에 자유롭게 이용해도 됩니다. 다만 스프링을 사용한다는 점과 원 저작자를 밝히고 제품을 패키징할 때 라이선스 정보를 포함시키는 등의 기본적인 의무사항을 따르면 됩니다. 또, 필요하다면 스프링 소스코드를 가져와 수정해서 사용할 수도 있습니다. 수정을 했더라도 수정한 소스를 공개해야 하는 의무는 없습니다.


대부분의 오픈소스 프로젝트처럼 스프링도 오픈소스 개발과 사용자를 위한 온라인 커뮤니티가 있습니다. 커뮤니티를 통해 자유롭게 개발에 관한 의견을 공유하거나 토론할 수도 있고 자신이 발견한 버그를 신고하거나 새로운 기능을 추가해달라고 요청할 수도 있습니다. 그런 요청이나 버그 신고가 어떻게 처리되고 있는지도 이슈트래커 시스템을 통해 공개적으로 확인이 가능하며, 수정된 코드도 언제든지 살펴볼 수 있습니다.


이렇게 개발 과정에 많은 사람이 자유롭게 참여한다는 것이 오픈소스 프로젝트로서 스프링이 가진 장점입니다. 그러나 스프링의 개발 과정은 공개되어 있지만 공식적인 개발은 제한 인원의 개발자에 한정됩니다. 원한다고 아무나 개발팀에 들어와 스프링 프레임워크 코드 개발에 참여할 수는 없습니다. 실제로 스프링은 대형 IT 기업의 사업부인 스프링소스(SpringSource)가 그 개발을 전적으로 책임지고 전담하고 있습니다. 비록 개발 과정이 공개되어 있고, 간접적으로 개발에 영향을 줄 수 있는 의견 제시나 패치 제공, 버그 신고, 공개적인 토론 등이 가능하다고 할지라도 직접적으로 스프링을 개발하는 일은 특정 조직에 소속된 개발자로 한정되 었다는 것입니다. 이렇게 개발팀이 폐쇄적으로 운영되고 있다는 사실은미션크리티컬한 시스템 개발에도 사용되는 엔터프라이즈 프레임워크인 스프링 입장에서는 중요한 의미가 있습니다.


모든 것이 다 그렇겠지만 오픈소스라는 것도 장단점이 있습니다.


오픈소스의 장점은 공개된 커뮤니티의 공간 안에서 투명한 방식으로 다양한 참여를 통해 개발되기 때문에 매우 빠르고 유연한 개발이 가능하다는 것입니다. 오픈소스 제품의 사용자는 소스코드를 다운받아서 품질과 기능을 얼마든지 검증하고 분석해볼 수 있습니다. 발견한 버그를 신고하거나 기능 개선을 제안했다면 그것이 어떻게 처리되는지도 지켜볼 수 있습니다. 개발 중인 경우에도 소스코드까지 투명하게 공개되기 때문에 다양한 현장에 있는 사용자의 피드백이 그만큼 빨리 전달되고 반영됩니다. 인기있는 오픈소스 제품이라면 베타 버전임에도 전 세계의 수많은 개발자가 자발적으로 다운받아서 사용해보고 다양한 방식으로 피드백을 주기도 합니다.


오픈소스 개발 모델을 사용하는 스프링 역시 전 세계의 많은 엔터프라이즈 시스템 개발자의 참여를 통해 발전해왔습니다. 때로는 새로운 기능에 대한 아이디어나 만들어진 코드를 제공받아서 적용하기도 했습니다. 포럼이나 이슈트래커를 통해 제안된 기능이나 요청사항 등이 다음 버전에 적용되는 사례를 꼽자면 끝도 없을 정도입니다. 자바 엔터프라이즈 환경이라는 게 워낙 다양하기 때문에 개발팀이 모든 환경과 기술 조합에 대해 일일이 테스트해보기는 불가능합니다. 하지만 다양한 환경에서 개발하는 개발자가 자신이 경험한 문제점이나 발견한 버그 등을 그때마다 커뮤니티를 통해 개발팀에 전달하기 때문에 잠재적인 버그와 문제점이 빠르게 발견되고 해결될 수 있습니다.


물론 오픈소스 제품을 사용하는 기업이나 사용자 입장에서 보자면 라이선스 비용에 대한 부담이 없다는 것도 큰 장점으로 꼽을 수 있습니다.


이런 여러 가지 장점이 있기 때문에 오픈소스 개발 모델은 이제 비영리 개발그룹에서만이 아니라 상용 제품을 만들고 영리를 추구하는 일반 기업에서도 적극적으로 이용합니다. 대형 소프트웨어 개발업체가 자신이 만든 제품의 일부 또는 전체 소스코드를 오픈소스 커뮤니티에 기증하거나 기업 웹사이트 등을 통해 공개하는 일도 적지 않게 일어나고 있습니다.


하지만 오픈소스 개발 모델에는 단점도 있습니다. 오픈소스 개발 방식의 가장 큰 취약점은 지속적이고 안정적인 개발이 계속될지가 불확실하다는 것입니다. 상당수의 오픈소스 제품은 핵심 개발자의 여가시간을 이용해 일종의 취미활동으로 만들어집니다. 그런데 개발자의 개인적인 사정으로 인해 개발을 더 진행할 수 없거나, 개발자가 중간에 교체되거나, 개발팀에 불화가 생겨서 개발을 정상적으로 진행하기가 힘들 때도 종종 있습니다. 어떤 때는 단순한 버그 하나가 수정되기까지 몇 년씩 걸리거나 개발자들이 다 떠나서 프로젝트 자체가 사장되는 최악의 상황까지도 갈 수 있습니다. 개발 프로젝트라는 게 대부분 그렇긴 하지만, 오픈소스 프로젝트는 특히 개발자 개개인에게 극히 의존적입니다.


스프링 같은 프레임워크는 기업의 가장 중요한 핵심 업무를 관장하는 엔터프라이즈 시스템의 개발에 사용됩니다. 오류가 발생하거나 문제가 생기면 치명적일 수 있는 미션크리티컬한 시스템의 개발에도 사용됩니다. 그런 데서 기반이 되는 프레임워크가 버그가 있는 채로 방치된다거나, 지속적으로 안정적인 개발이 진행되지 못한다는 건 심각한 문제입니다. 그런 이유 때문에 엔터프라이즈 시스템 개발자는 언제 개발이 중단되거나 지연될지 모르는 오픈소스 프레임워크의 도입을 부정적으로 생각할 수 밖에 없었습니다.


스프링 개발자는 이런 오픈소스의 문제점과 한계를 잘 알고 있었습니다. 그래서 오픈소스 개발이라는 방법을 선택하기는 했지만 프레임워크 사용자에게 지속적인 신뢰를 줄 수 있도록 개발을 책임지고 진행할 수 있는 전문 기업을 만들었습니다. 이를 통해 스프링의 핵심 개발자가 파트타임이나 여가시간 대신 정규 업무시간에 풀타임으로 오픈소스 개발에 전념할 수 있었고, 덕분에 안정적이고 전문화된 개발과 품질관리가 가능해졌습니다. 기존 오픈소스 개발 방식의 단점을 극복할 수 있는 대안은 기업이나 기가관의 지원을 받는 전문 개발자가 오픈소스 개발을 책임지게 하는 것입니다.


스프링을 개발하고 있는 스프링소스는 스프링의 창시자인 로드 존슨을 비롯해 스프링이 오픈소스화되는 데 가장 큰 역할을 한 유겐 횔러와 자바 엔터프라이즈 세계에서 손꼽히는 최상급 개발자들이 주축이 돼서 만든 회사입니다. 이 회사는 스프링에 대한 전문적인 기술지원과 컨설팅 그리고 스프링을 기반으로 개발된 시스템을 안정적으로 운용할 수 있도록 돕는 상용 제품을 제공함으로써 수익을 얻고, 한편으로는 오픈소스 프로젝트로서 스프링이 효율적으로 개발되도록 지원하고 있습니다.


엔터프라이즈 영역에서 사용되는 대표적인 오픈소스 제품의 경우는 이렇게 특정 기업이 주도하거나 지원하는 방식으로 개발되는 일이 상당히 보편화됐습니다. 사용자는 개발이 중단될까봐 염려하지 않아도 되고, 필요한 경우에는 비용을 지불하고 개발팀의 전문적인 기술지원 서비스나 컨설팅을 받을 수도 있기 때문입니다.


스프링 개발업체인 스프링소스는 2009년에 세계적인 IT 기업인 VMWare에 전략적으로 합병됐습니다. 그 덕분에 이전보다 더욱 안정된 환경과 조직의 지원을 통해 오픈소스 스프링의 개발에 더욱 전념할 수 있었습니다.


스프링은 오픈소스의 장점을 충분히 취하면서 동시에 오픈소스 제품의 단점과 한계를 잘 극복하고 있는, 전문적이고 성공적인 오픈소스 소프트웨어라고 할 수 있습니다.



2. 스프링의 목적


지금까지 스프링의 정의를 살펴봄으로써 스프링의 기본적인 특징을 알아보았습니다. 이번에는 좀 더 구체적으로 스프링의 개발 철학과 궁극적인 목표가 무엇인지를 생각해보겠습니다. 모든 기술이나 지식이 다 그렇지만, 스프링은 더더욱 그 목표를 분명히 알고 사용하지 않으면 그 가치를 제대로 얻기 힘듭니다. 그저 스프링을 가져다가 어떻게든 사용해서 개발만 하면 스프링을 적용한 것이고, 스프링의 장점이 개발에 반영됐다고 할 수 있을까요? 결코 그렇지 않습니다. 스프링을 사용하기는 해도 스프링이 주는 혜택을 전혀 누리지 못하고 오히려 사용하지 않느니만 못한 경우도 적지 않습니다.


스프링을 제대로 사용하는 건 생각보다 쉽지 않습니다. 이런 식으로 만들면 된다는 표준 샘플이 있는 것도 아닙니다. 스프링의 개발 표준 따위가 존재하지도 않지만, 스프링 적용 베스트 프랙티스를 모아다가 그대로 따른다고 해도 스프링을 잘 사용하고 있다고 확신할 수는 없습니다. 레퍼런스 매뉴얼을 착실히 읽고 관련 서적을 여러 권 공부한다고 해도 스프링을 사용해 어떻게 개발해야 할지 막막할 수도 있습니다.


스프링은 그 기능과 API 사용 방법을 잘 안다고 해서 잘 쓸 수 있는게 아닙니다. 이 말이 이상하게 들릴지도 모르겠지만 자바를 처음 배울 때를 생각해보면 이해가 될 것입니다. 자바 언어 문법과 JDK의 API 사용법이 자세히 설명된 두꺼운 자바 입문서를 읽고, 더 욕심을 내면 웬만한 철학서적보다도 더 지루한 자바 언어 스펙까지도 공부했다고 자바로 개발을 잘할 수 있을까요? 자바의 장점을 잘 살려 애플리케이션을 개발할 수 있을 까요? 그렇지 않다는 것은 자바로 개발을 해온 개발자라면 누구나 잘 알 것입니다. 자바 언어와 JDK 라이브러리는 모두 일종의 편리한 도구로서 자바 언어의 특징인 객체지향 프로그래밍을 좀 더 손쉽게 할 수 있도록 돕고 있을 뿐입니다. 자바로 개발을 잘하려면 결국 근본적인 프로그래밍 실력이 필요합니다. 자바의 근본적인 목적은 객체지향 프로그래밍을 통해 유연하고 확장성 좋은 애플리케이션을 빠르게 만드는 것입니다. 자바를 가져다가 절차지향 언어처럼 사용한다면 자바를 사용하는 가치를 얻을 수 없습니다.


마찬가지로 스프링도 목적을 바로 이해하고, 그 목적을 이루는 도구로 스프링을 잘 활용해야만 스프링으로부터 제대로 된 가치를 얻을 수 있습니다. 어떤 기술이든 그 자체로는 도구에 불과합니다. 그것을 용도에 맞게 잘 활용해서 궁극적으로 이루고자 하는 목표를 이루는 것이 중요하지, 도구의 사용법만 열심히 익힌다고 결과를 저절로 얻을 수 있는 것은 아닙니다.


그렇다면 스프링의 목적은 무엇일까요? 스프링이 만들어진 이유는 무엇이고, 스프링을 통해 궁극적으로 이루려고 하는 것은 무엇일까요? 그것은 정의를 통해 살펴봤듯이 '경량급 프레임워크인 스프링을 활용해서 엔터프라이즈 애플리케이션 개발을 편하게' 하는 것입니다. 그렇다면 굳이 스프링을 사용해서 엔터프라이즈 개발을 편하게 하려는 이유는 뭘까요? 원래 엔터프라이즈 개발이란 편하지 않기 때문입니다.



2-1. 엔터프라이즈 개발의 복잡함

2000년대 초반 각종 자바 컨퍼런스에서 자주 논의됐던 주제는 '왜 자바 엔터프라이즈(JavaEE) 프로젝트는 실패하는가?'였습니다. 당시 IT 리서치기업의 조사에 따르면 80% 이상의 자바 엔터프라이즈 프로젝트가 실패했다고 합니다. 프로젝트가 아예 중단되고 취소된 것까지는 아니더라도, 원래 정해진 기간과 계획된 예산을 맞추지 못한 경우가 그만큼 많다는 뜻입니다. 또는 원하는 만큼의 기능과 완성도를 갖춘 시스템을 못 만들고 적당히 마무리하기도 했을 것입니다. 아무튼 자바 엔터프라이즈 개발이 실패하는 이유에 대해 많은 논의가 있었습니다. 그 과정에서 밝혀진 여러 가지 원인이 있었지만, 그중 가장 대표적인 게  '엔터프라이즈 시스템 개발이 너무 복잡해져서'였습니다.


복잡함의 근본적인 이유

그렇다면 엔터프라이즈 시스템 개발은 왜 복잡할까요? 크게 두 가지 원인을 생각해볼 수 있습니다.


첫번째는 기술적인 제약조건과 요구사항이 늘어가기 때문입니다.

엔터프라이즈 시스템이란 서버에서 동작하며 기업과 조직의 업무를 처리해주는 시스템을 말합니다. 엔터프라이즈 시스템은 많은 사용자의 요청을 동시에 처리해야 하기때문에 서버의 자원을 효율적으로 공유하고 분배해서 사용할 수 있어야 합니다. 또한 중요한 기업의 핵심 정보를 처리하거나 미션 크리티컬한 금융, 원자력, 항공, 국방 등의 시스템을 다루기도 하기 때문에 보안과 안정성, 확장성 면에서도 뛰어나야 합니다. 따라서 뛰어난 성능과 서비스의 안정성이 요구되고 그런 점을 고려한 개발 기술이 필요합니다. 즉 엔터프라이즈 시스템을 개발하는 데는 순수한 비즈니스 로직을 구현하는 것 외에도 기술적으로 고려할 사항이 많다는 뜻입니다. 또 웹을 통한 사용자의 인터페이스뿐만 아니라, 타 시스템과의 자동화된 연계와 웹 이외의 클라이언트와의 접속을 위한 리모팅 기술도 요구됩니다. 기업의 시스템이 복잡함에 따라 다중 데이터베이스를 하나의 트랜잭션으로 묶어서 사용하는 분산 트랜잭션의 지원도 필요합니다. 문제는 이러한 엔터프라이즈 시스템의 기술적인 요구사항은 단순히 고가의 애플리케이션 서버(WAS)나 툴을 사용한다고 충족될 수 있는게 아니라는 점입니다. 따라서 이런 종류의 기술적인 문제를 고려하면서 애플리케이션을 개발해야 하는 부담을 안게됩니다.

엔터프라이즈 시스템이 기업 업무를 처리하는 데 핵심적인 역할로 등장하고 중요해지면서 점점 더 기술적인 요구는 심화되고 그에 따른 복잡도는 증가합니다. 이전에는 그다지 신경쓰지 않았던 보안에 관한 부분도 갈수록 중요해지고, 그에 따라 시스템 설계자와 개발자 개개인이 져야 할 기술적인 부담은 점점 더 커져갔습니다.


두번째는 엔터프라이즈 애플리케이션이 구현해야 할 핵심기능인 비즈니스 로직의 복잡함이 증가하기 때문입니다.

예전에는 기업 업무 중 회계처럼 복잡한 계산이나 빠른 분석 작업이 필요한 영역에서만 IT 시스템을 활용했습니다. 하지만 갈수록 엔터프라이즈 시스템을 이용해 기업의 핵심 업무를 처리하는 비율이 늘어갔고, 점차 대부분의 업무 처리는 컴퓨터를 이용하지 않고는 아예 진행하기 힘들 만큼 엔터프라이즈 시스템에 대한 업무 의존도가 높아졌습니다. 그만큼 다양하고 복잡한 업무 처리 기능을 엔터프라이즈 시스템이 구현해야 했다는 뜻입니다. 원래 기업 업무란 그 자체로 복잡한데다, 다양한 예외상황도 많고, 처리해야 하는 정보의 규모도 상당합니다. 엔터프라이즈 시스템이 관여하는 업무의 비율이 급격하게 커지고 있으니 당연히 애플리케이션 개발도 힘들도 복잡해져 가는 것입니다.


더 큰 문제는 2000년 전후로 전 세계에 불어 닥친 경제위기가 기업의 체질을 크게 바꿨다는 사실입니다. 한번 업무 프로세스와 정책이 결정되면 제법 오랫동안 유지하던 전통적인 기업조차도 경제 흐름과 사회의 변화, 업계의 추이에 따라서 수시로 업무 프로세스를 변경하고 조종하는 것을 상시화할 만큼 변화의 속도가 빨라졌습니다. 결국 이런 업무구조와 프로세스의 변화는 이를 뒷받침해줘야 하는 엔터프라이즈 시스템의 변경을 요구할 수 밖에 없습니다. 버그나 오류가 있어서가 아니라, 기능 요구사항과 업무 정책 등이 바뀌기 때문에 애플리케이션을 자주 수정해줘야 하는 시대가 된 것입니다. 그만큼 이전과 다르게 시스템 개발과 유지보수, 추가 개발 등의 작업에 대한 부담은 커지고 그에 따른 개발의 난이도는 더욱 증가한 것입니다.



복잡함을 가중시키는 원인

엔터프라이즈 애플리케이션 개발이 실패하는 주요 원인은 비즈니스 로직의 복잡함과 기술적인 복잡함입니다. 복잡하다는 건 단지 양이 많고 어렵다는 뜻이 아닙니다. 세부 요소가 이해하기 힘든 방식으로 얽혀 있고, 그 때문에 쉽게 다루기 어렵다는 의미입니다. 자칫 잘못 손을 댔다가는 더 엉망이 되기 쉬우며, 들인 노력과 시간이 허사가 될 수도 있습니다. 자바 엔터프라이즈 시스템 개발이 어려운 가장 큰 이유는 근본적인 비즈니스 로직과 엔터프라이즈 기술이라는 두 가지 복잡함이 한데 얽혀 있기 때문입니다. 하나씩 놓고 봐도 만만치 않은데, 그 두가지를 한 번에 다뤄야 하니 복잡함이 몇 배로 가중되는 것입니다.


예를 들면 이런 경우입니다. 고객의 기존 거래내역을 분석하고 그 특성을 파악해서 그에 따른 적절한 추천상품을 선정하는 로직을 담당하는 코드를 작성한다고 생각해보겠습니다. 그런데 그 작업 요청을 XML 문서를 통한 리모팅 서비스로 받기 때문에 그것을 파싱해서 고객 ID를 추출하기 위해 XML 파서 라이브러리를 사용해야 하고, 고객의 최신 정보를 얻기 위해 DB를 조회할 때 캐시를 먼저 점검하려고 캐시 API를 호출하고, 없으면 서버가 제공하는 DB 풀에서 커넷션을 가져와서 JDBC API를 이용해 다양한 타입의 필드로 된 정보를 가져와야 합니다. 그대 가져온 정보를 분석한 내용을 만일을 위해 로그로 남겨놓도록 분산 파일 시스템을 이용하는 로그 라이브러리를 매번 호출한다. 현재 요청을 보낸 사용자의 정보를 보안 API를 통해 가져와 요청한 작업에 대한 권한이 있는지도 파악해야 하고, 권한이 없으면 그에 따른 예외를 발생시켜야 합니다. 최종적으로 추천상품으로 선정한 내역을 로컬 DB에 저장하고 메시지로도 전송해야 하는데, 반드시 하나의 트랜잭션 안에서 동작하도록 하기 위해 JTA를 이용해야 한다고 생각해보겠습니다.


고객에 대한 추천제품 선정이라는 비즈니스 로직을 제대로 구현하는 일도 만만치 않은데 동시에 이런저런 다양한 기술적인 문제도 함께 신경 써야 한다면 어떨까요? 각종 엔터프라이즈 기술 서비스를 적용하기 위한 코드와 각종 기술적인 API의 호출 코드를 비즈니스 로직에 대한 구현 코드와 함께 덕지덕지 붙여서 만드는 것은 매우 어렵습니다. 더 큰 문제는 그렇게 기술과 비즈니스 로직의 복잡함에 엉켜있는 코드를 유지보수하는 일입니다. 만약 적용한 기술을 변경해야 한다면? 또는 특정 로직을 수정해야 한다면? 하나의 수정 요구를 적용하기 위해 복잡하게 얽혀 있는 코드를 헤매다 보면 정작 수정할 대상이 아닌 부분에까지 영향을 줘서 새로운 버그를 만들 수도 있습니다.


일반적으로 사람은 성격이 다른 두 가지 종류의 일을 동시에 생각하고 처리하는 데 매우 취약합니다. 그럼에도 전통적인 자바 엔터프라이즈 개발 기법은 대부분 비즈니스 로직의 복잡한 구현 코드와 엔터프라이즈 서비스를 이용하는 기술적인 코드가 자꾸 혼재될 수 밖에 없는 방식이었습니다. 결국 개발자가 동시에 그 두 가지를 모두 신경 써서 개발해야 하는 과도한 부담을 줬고, 그에 따라 전체적인 복잡함은 몇 배로 가중됐습니다.



2-2. 복잡함을 해결하려는 도전


제거될 수 없는 근본적인 복잡함

엔터프라이즈 개발의 근본적인 복잡함의 원인은 제거할 대상은 아닙니다. 물론 구현해야할 비즈니스 로직의 적용범위를 줄이고, 기술적인 요구조건을 일부 생략한다면 그만큼 개발은 편해질 것이고 적어도 실패하지 않을지는 모릅니다. 하지만 현실적으로는 불가능합니다. 기술적인 복잡함을 해결하고자 보안을 취약하게 방치한다거나, 사용자가 늘어나도 더 이상 확장이 불가능한 시스템을 만들 수는 없습니다. 기업의 업무 처리에서 IT가 차지하는 비중을 생각해볼 때 업무의 일부를 다시 수작업으로 가져가서 시스템 개발의 부담을 줄이겠다는 것도 말이 되지 않습니다.


결국 근본적으로 엔터프라이즈 개발에 나타나는 복잡함의 원인은 제거 대상이 아닙니다. 대신 그 복잡함을 효과적으로 상대할 수 있는 전략과 기법이 필요합니다. 문제는 비즈니스 로직의 복잡함을 효과적으로 다루기 위한 방법과 기술적인 복잡함을 효과적으로 처리하는 데 적용되는 방법이 다르다는 점입니다. 따라서 두 가지 복잡함이 코드에 한데 어우러져 나타나는 전통적인 개발 방식에서는 효과적으로 복잡함을 다루기가 힘듭니다.


따라서 가장 먼저 할 일은 성격이 다른 이 두가지 복잡함을 분리해내는 것입니다.


실패한 해결책: EJB

EJB가 처음 등장했을 때 내세웠던 목표를 봐도 알 수 있듯이 EJB의 기본 전략도 이 두가지 종류의 복잡함을 분리하는 것이었습니다. 개발자가 로우레벨의 기술적인 복잡함에 신경 쓰지 않고 비즈니스 로직을 효과적으로 개발하는 데 더 집중할 수 있게 하자는 목표가 있었습니다.


하지만 기존 EJB는 결과적으로 그런 목표를 달성하는 데 실패했습니다. EJB는 기술적인 복잡함을 애플리케이션의 핵심 로직에서 일부분 분리하는 데 성공하긴 했습니다. 선언적 트랜잭션이나 선언적 보안, 컨테이너를 통한 리모팅 기술의 적용, 컴포넌트 단위의 배치, JNDI를 통한 서비스 검색 지원, 서비스 오브젝트의 풀링, 컴포넌트 생명주기 관리 등은 EJB의 목표를 어느 정도 충족시켰습니다. 반면에 EJB 환경에서 동작하기 위해 특정 인터페이스를 구현하고, 특정 클래스를 상속하고, 서버에 종속적인 서비스를 통해서만 접근하고 사용이 가능하게 만드는 등의 EJB 개발 방식은 잘못된 선택이었습니다. 애플리케이션 로직을 담은 핵심 코드에서 일부 기술적인 코드가 제거된 건 사실이지만, 오히려 EJB라는 환경과 스펙에 종속되는 코드로 만들어져야 하는 더 큰 부담을 안게 됐습니다.


EJB는 결국 일부 기술적인 복잡함을 덜어주려는 시도를 하다가 오히려 더 큰 복잡함을 추가하는 실수를 범했습니다. 가장 치명적인 건, EJB라는 틀 안에서 자바 코드를 만들게 강제함으로써 자바 언어가 원래 갖고 있던 장점마저 잃어버렸다는 사실입니다. EJB의 특정 클래스를 상속하게 함으로써 더 이상 상속구조를 적용하지 못하게 만들거나, 다형성 적용을 근본적으로 제한한다거나 하는 것들입니다. EJB는 결국 객체지향적인 특성을 잃어버린 밋밋한 서비스 스크립트성 코드로 변질돼갔습니다. 별다른 장점은 없는데다 개발 방식은 너무 불편했기 때문에 개발자에게 점점 외면당하는 신세가 돼버렸습니다.


물론 스프링이 처음 등장했을 때 EJB와는 달리, 그 후에 등장한 EJB는 훨씬 개선되긴 했지만 여전히 서버환경에 의존적인 기능을 요구하는 등의 단점이 남아 있는데다, 한번 인식이 나빠진 이후로는 개발자에게 점차 외면되고 있는 것이 현실입니다. 게다가 EJB의 발전주기는 너무 느려서 엔터프라이즈 개발 기술의 발전을 따라잡지 못하는 것도 문제점입니다.



비침투적인 방식을 통한 효과적인 해결책: 스프링

스프링은 EJB의 실패를 교훈으로 삼아서 출발했습니다. EJB의 처음 목표와 마찬가지로 기술적인 복잡함을 애플리케이션 핵심 로직의 복잡함에서 제거하는 데 목표를 뒀습니다. 하지만 그 과정에서 EJB처럼 개발자의 코드에 난입해서 지저분하고 복잡한 코드를 만들어버리는 실수를 하지는 않았습니다. EJB처럼 어떤 기술을 적용했을 때 그 기술과 관련된 코드나 규약 등이 코드에 등장하는 경우를 침투적인 기술이라고 합니다. 물론 꼭 필요한 기능을 사용해야 하기 때문에 특정 기술의 API를 이용하게 되는 건 어쩔 수 없습니다. 그런데 꼭 필요한 기능을 사용하는 것도 아니면서 단지 어떤 기술을 바탕으로 만들어진다고해서 특정 클래스나 인터페이스, API 등의 코드에 마구 등장한다면 그것은 침투적인 기술이 되며 복잡함을 가중시키는 원인이 됩니다. 


반면에 비침투적인(non-invasive) 기술은 기술의 적용 사실이 코드에 직접 반영되지 않는다는 특징이 있습니다. 어딘가에는 기술의 적용에 따라 필요한 작업을 해줘야 하겠지만, 애플리케이션 코드 여기저기에 불쑥 등장하거나, 코드의 설계와 구현 방식을 제한하지는 않는다는 게 비침투적인 기술의 특징입니다.


스프링이 성공할 수 있었던 비결은 바로 비침투적인 기술이라는 전략을 택했기 때문입니다. 스프링을 이용하면 기술적인 복잡함과 비즈니스 로직을 다루는 코드를 깔끔하게 분리할 수 있습니다. 중요한 점은 그 과정에서 스프링 스스로가 애플리케이션 코드에 불필요하게 나타나지 않도록 하는 것입니다. 꼭 필요한 것 같은 경우조차도 기술 코드가 직접 노출되지 않도록 만들어줬습니다.


결과는 성공적이었습니다. 물론 스프링을 적용한다고 해서 근본적인 복잡함의 원인이 사라지는 건 아닙니다. 하지만 스프링을 통해 성격이 다른 복잡함들을 깔끔하게 분리해줬기 때문에 각각을 효과적으로 상대할 수 있는 기반이 마련됐습니다. 동시에 스프링이 코드에 불필요하게 등장해서 부가적인 복잡함을 가져오지도 않았습니다. 이러한 전략 덕분에 많은 프로젝트를 실패로 몰아가고 비효율적인 개발로 개발자를 고생시켰던 문제를 공략할 수 있게 된 것입니다.



2-3. 복잡함을 상대하는 스프링의 전략

스프링의 기본적인 전략은 비즈니스 로직을 담은 애플리케이션 코드와 엔터프라이즈 기술을 처리하는 코드를 분리시키는 것입니다. 이 분리를 통해 두 가지 복잡함의 문제를 효과적으로 공략하게 해줍니다.


기술적 복잡함을 상대하는 전략

기술적인 복잡함을 분리해서 생각하면 그것을 효과적으로 상대할 수 있는 적절한 전략을 발견할 수 있습니다. 스프링은 엔터프라이즈 기술을 적용했을 때 발생하는 복잡함의 문제를 두가지로 분류하고 각각에 대한 적절한 대응 방법을 제공합니다.


첫번째 문제: 기술에 대한 접근 방식이 일관성이 없고, 특정 환경에 종속적이다.

환경이 바뀌고, 서버가 바뀌고, 적용되는 조건이 바뀌면 적용하는 기술이 달라지고 그에 따라 코드도 바뀐다는 건 심각한 문제다. 비록 동일한 목적으로 만들어졌지만 API의 사용방법이 다르고, 접근 방식이 다른 기술로 난립하는 것이 현실입니다. 그래서 목적이 유사하지만 호환이 안 되는 표준, 비표준, 오픈소스, 상용 제품 등이 제공하는 각기 다른 API를 사용하도록 코드를 일일이 변경해야 하는 번거로움이 발생한다.


이렇게 일관성 없는 기술과 서버환경의 변화에 대한 스프링의 공략 방법은 바로 서비스 추상화다. 앞에서 살펴봤던 트랜잭션 추상화나 OXM 추상화, 데이터 액세스에 관한 일관된 예외변환 기능, 데이터 액세스 기술에 독립적으로 적용 가능한 트랜잭션 동기화 기법 등이 그런 대표적인 예다. 기술적인 복잡함은 일단 추상화를 통해 로우레벨의 기술 구현 부분과 기술을 사용하는 인터페이스를 분리하고, 환경과 세부 기술에 독립적인 접근 인터페이스를 제공하는 것이 가장 좋은 해결책이다.


때론 자바메일과 같이 해당 기술을 사용하는 코드의 테스트를 어렵게 만드는, 그럼에도 표준으로 떡 하니 자리 잡은 기술에 대해서도 서비스 추상화를 적용할 필요가 있다. 이를 통해 테스트 편의성을 증대시키고 기술에 대한 세부 설정과 환경으로부터 독립적인 코드를 만들 수 있다.


데이터 액세스 예외에 대한 추상화는 비즈니스 로직을 담은 서비스 레이어의 코드가 특정 기술이 발생시키는 예외에 종속되지 않고, 불필요하게 예외를 잡아야 하거나 throws를 선언해야 하는 것을 방지해준다.


스프링이 제공하는 템플릿/콜백 패턴은 판에 박힌 반복적인 작업 흐름과 API 사용 코드를 제거해준다. 이를 통해 기술을 사용하는 코드도 최적화된 핵심 로직에만 집중하도록 도와준다.



두번째 문제: 기술적인 처리를 담당하는 코드가 성격이 다른 코드에 섞여서 등장한다.

앞에서도 살펴봤듯이 비즈니스 로직 전후로 경계가 설정돼야 하는 트랜잭션, 비즈니스 로직에 대한 보안 적용, 계층 사이에 주고받는 데이터와 예의 일괄 변환이나 로깅이나 감사 기능 등이 대표적인 예다. 책임에 따라 계층을 구분하고 그 사이에 서로의 기술과 특성에 의존적인 인터페이스나 예외처리 등을 최대한 제거한다고 할지라도 근본적으로 엔터프라이즈 서비스를 적용하는 한 이런 문제를 쉽게 해결할 수 없다. 이런 기술과 비즈니스 로직의 혼재로 발생하는 복잡함을 해결하기 위한 스프링의 접근 방법은 바로 AOP다.


AOP는 최후까지 애플리케이션 로직을 담당하는 코드에 남아 있는 기술 관련 코드를 깔끔하게 분리해서 별도의 모듈로 관리하게 해주는 강력한 기술이다. AOP를 적용하지 않았을 때는 기술과 비즈니스 로직이 지저분하게 얽혀서 다루기 힘들다는 문제도 있지만, 기술적인 코드가 여기저기 중복돼서 나타난다는 것도 심각한 문제점이다. 이 때문에 기술적인 작업을 처리하는 방식이 변경될 경우 많은 곳을 수정해야 한다. AOP는 기술을 다루는 코드로 인한 복잡함을 기술 그 자체 이상으로 불필요하게 증대되지 않도록 도와주는 가장 강력한 수단이다.


비즈니스와 애플리케이션 로직의 복잡함을 상대하는 전략

기술적인 코드, 침투적인 기술이 가져온 불필요한 흔적 등을 제거하고 나면 순수하게 애플리케이션의 주요 기능과 비즈니스 로직을 담은 코드만 독립적으로 존재하게 됩니다. 이 중에서 기술적인 부분과 느슨하게나마 연관되는 데이터 처리 코드나 웹이나 리모트 인터페이스 코드 등을 제외하면 비즈니스 로직 코드를 다루는 코드가 남게 됩니다. 비즈니스 로직을 담은 코드는 애플리케이션에서 가장 중요한 핵심이 되는 부분입니다. 또한 업무의 변화에 따라 자주 변경되거나 수정되는 부분이기도 합니다. 따라서 대체로 복잡합니다. 자주 바뀌는 업무 정책, 비즈니스 규칙, 업무 흐름을 담고 있을 뿐만 아니라 복잡한 데이터를 분석하고 그에 따른 작업을 수행하고, 클라이언트가 필요한 결과를 만들어내야 하기도 합니다. 기술적인 부분이나 사용자 인터페이스에 관한 오류가 발생했을 겨우에는 시스템을 복구하거나 빠르게 대응해주면 당장에 큰 문제가 발생하지는 않습니다. 반면에 비즈니스 로직을 다루는 핵심 코드에 오류가 있으면 엔터프라이즈 시스템을 사용하는 업무 자체에 큰 지장을 주거나 치명적인 손실을 끼칠 수도 있습니다. 


증권사의 거래 사이트가 사용자가 늘어났지만 확장성이 떨어져서 가끔 서비스가 느려지는 문제라면 시스템을 리셋하든, 서버를 증설하든 어떻게든 대응을 하면 됩니다. 기술적인 문제도 방치할 수는 없지만 대부분은 심각한 상황까지 가지는 않습니다. 반면에 비즈니스 로직에 오류가 발생하면 엄청난 사고로 이어질 수 있습니다. 증권사 사이트를 통해 주식거래를 분명히 완료했는데도 실제로는 체결이 되지 않았다거나 계좌의 잔액이 이유도 없이 줄어든다면 어떻게 될까? 아마 당장에 성난 고객들이 몰려와서 난동을 부리고, 자칫하면 회사 문을 닫아야 할지도 모릅니다.


그래서 비즈니스 로직은 가장 중요하게 다뤄져야 하고 가장 많이 신경써야 합니다. 예전에는 비즈니스 로직의 상당 부분을 DB에 두는 것이 유행이었습니다. SQL을 통해 비즈니스 로직을 표현하고, DB에서 동작하는 저장 프로시저를 통해 핵심 로직을 처리하는 경우도 많았습니다. 하지만 엔터프라이즈 시스템의 규모가 커지고, 복잡함이 증가하면서 DB에 비즈니스 로직을 두는 건 매우 불편할뿐더러 위험한 일이라고 여겨지기 시작했습니다.


가장 확장하기도 힘들고 확장하더라도 많은 비용이 드는 공유 자원인 DB에 커다란 부담을 주는 것도 문제고, 데이터 액세스를 중심으로 로직을 다루면 개발과 유지보수는 물론이고 테스트도 매우 어렵게 됩니다.


따라서 엔터프라이즈 시스템 개발의 흐름은 점차로 비즈니스 로직은 애플리케이션 안에서 처리하도록 만드는 추세입니다. DB는 단지 데이터의 영구적인 저장과 복잡한 조건을 가진 검색과 같은 자체적으로 특화된 기능에만 활용하고, 데이터를 분석하고 가공하고 그에 따라 로직을 처리하는 부분은 확장하기 쉽고, 비용도 싼 애플리케이션 서버 쪽으로 이동하는 것입니다. 오브젝트에 담긴 로직은 테스트하기도 쉽습니다. 목 오브젝트 등을 이용하면 심지어 DB가 없어도 테스트를 할 수 있습니다. 게다가 CBD를 비롯한 최신 설계와 개발 기법, 모델링을 중심으로 한 개발 방법은 오브젝트 기반의 설계와 구현에 잘 들어맞습니다.


자바는 객체지향 언어의 장점을 잘 살려서 설계된 언어입니다. 객체지향 프로그래밍 기법과 언어가 주는 장점인 유연한 설계가 가능하고 재상용성이 높다는 점을 잘 활용하면 자주 바뀌고 조건이 까다로운 비즈니스 로직을 효과적으로 구현해낼 수 있습니다. 객체지향 분석과 설계(OOAD)를 통해서 작성된 모델을 코드로 구현하고 지속적으로 발전시킬 수도 있습니다.


환경에 종속적인 기술과 침투적인 기법으로 인해 추가된 군더더기에 방해만 받지 않는다면 객체지향 언어로서의 장점을 잘 살려 비즈니스 로직의 복잡함을 최대한 효과적으로 다룰 수 있는 깔끔한 코드를 만드는 건 어렵지 않습니다.


물론 이 영역은 스프링조차 관여하지 않습니다. 비침투적인 기술인 스프링은 핵심 로직을 다루는 코드에는 스프링의 흔적조차 찾을 수 없을 만큼 자신을 드러내지 않습니다. 다만 뒤에서 비즈니스 로직을 담당하는 오브젝트들에게 적절한 에터프라이즈 기술 서비스가 제공되도록 은밀히 도와줄 뿐입니다.


결국 비즈니스 로직의 복잡함을 상대하는 전략은 자바라는 객체지향 기술 그 자체입니다. 스프링은 단지 객체지향 언어의 장점을 제대로 살리지 못하게 방해했던 요소를 제거하도록 도와줄 뿐입니다.



핵심 도구: 객체지향과 DI

기술과 비즈니스 로직의 복잡함을 해결하는 데 스프링이 공통적으로 사용하는 도구가 있습니다. 바로 객체지향(OO)입니다. 스프링 개발자는 자바 엔터프라이즈 기술의 가장 큰 장점은 바로 객체지향 설계와 프로그래밍을 가능하게 해주는 자바 언어라고 생각했습니다. 그런데 EJB 등이 등장해서 자바 언어의 객체지향 프로그램의 장점을 취하지 못하게 하면서, 특정 기술의 스펙에 종속된 설계 방식을 강요했다는 점에 불만을 가졌습니다.


스프링의 모토는 결국 "기본으로 돌아가자"입니다. 자바의 기본인 객체지향에 충실한 설계가 가능하도록 단순한 오브젝트로 개발할 수 있고, 객체지향의 설계 기법을 잘 적용할 수 있는 구조를 만들기 위해 DI 같은 유용한 기술을 편하게 적용하도록 도와주는 것이 스프링의 기본 전략입니다.


지금까지 살펴봤듯이 기술적인 복잡함을 효과적으로 다루게 해주는 기법은 모두 DI를 바탕으로 하고있습니다. 서비스 추상화, 템플릿/콜백, AOP와 같은 스프링의 기술은 DI없이는 존재할 수 없는 것들입니다.


그리고 DI는 객체지향 설계 기술이 없이는 그 존재의미가 없습니다. DI란 특별한 기술이라기보다는 유연하게 확장할 수 있는 오브젝트 설계를 하다 보면 자연스럽게 적용하게 되는 객체지향 프로그래밍 기법일 뿐입니다. 스프링은 단지 그것을 더욱 편하고 쉽게 사용하도록 도와줄 뿐입니다. 


객체지향 언어를 쓴다고 해서 자연스럽게 객체지향 설계가 되고 객체지향 프로그래밍을 할 수 있는 것은 아닙니다. 그래서 많은 개발자는, 심지어는 EJB의 설계자와 같은 세계적인 전문가도 그 가치를 놓칠 때가 있습니다. 그런 면에서 DI는 자연스럽게 객체지향적인 설계와 개발로 이끌어주는 좋은 동반자입니다. DI가 자연스럽게 확장성이 좋은 설계로 이끄는 과정을 생각해보겠습니다. DI를 의식하다 보면 오브젝트를 설계할 때 자주 DI를 적용할 후보가 더이상 없을까를 생각해보게 됩니다. 여기서 바뀔 수 있는 것은 무엇일까? 여기서 성격이 다르고, 변경의 이유가 다른 기능은 무엇일까? 그리고 그런 후보를 찾을 수 있다면 DI를 적용해서 오브젝트를 분리하고, 인터페이스를 도입하고, DI로 관계를 연결해줄 것입니다. 결국 DI는 좋은 오브젝트 설계의 결과물이기도 하지만, 반대로 DI를 열심히 적용하다 보면 객체지향 설계의 원칙을 잘 따르고 그 장점을 살린 설계가 나올 수도 있습니다.


그런 면에서 객체지향과 DI는 서로 떼놓고는 생각할 수 없습니다. 만약 스프링을 사용하고 DI를 적용했다고 하지만, 기계적인 방법으로 항상 사용하는 틀에 박힌 구조의 빈만 정의하고 나머지 코드에는 DI를 적용해볼 생각조차 안한다면 DI를 잘못 사용하고 있는 것입니다.


기술적인 복잡함을 해결하는 문제나 기술적인 복잡함이 비즈니스 로직에 침범하지 못하도록 분리하는 경우에도 DI가 바탕이 된 여러가지 기법이 활용됩니다. 반면에 비즈니스 로직 자체의 복잡함을 해결하려면 DI보다는 객체지향 설계 기법이 더 중요합니다. 왜 스프링이 힘들게 비즈니스 로직 자체에 기술적인 코드와 특정 기술의 스펙이 침범하지 않는 코드로 만들어주는 데 그토록 힘을 썼을까 생각해보겠습니다. 단지 좋은 코드가 좀 더 단순해지고 명확해지기 때문만은 아닙니다. 그보다는 순수한 비즈니스 로직만을 담고 있는 코드에는 객체지향 분석과 설계에서 나온 도메인 모델을 쉽게 적용할 수 있기 때문입니다. 객체지향적인 특성을 잘 살린 설계는 상속과 다형성, 위임을 포함해서 많은 객체지향 디자인 패턴과 설계 기법이 잘 녹아들어 갈 수 있습니다. 기술적인 코드에 침범당하지 않았다면 이런 설계를 비즈니스 로직을 구현하는 코드에 그대로 반영할 수 있습니다. 그래서 객체지향 기술의 장점을 최대한 활용해서 복잡하고 자주 변하는 업무를 지원하는 시스템을 만들 때도 손쉽게 대응이 가능해지는 것입니다.


결국 모든 스프링의 기술과 전략은 객체지향이라는 자바 언어가 가진 강력한 도구를 극대화해서 사용할 수 있도록 돕는 것이라고 볼 수 있습니다. 스프링은 단지 거들 뿐입니다. 현장의 업무를 잘 지원하고 유연하게 대응할 수 있는 뛰어난 애플리케이션을 만드는 것은 객체지향을 잘 활용해서 복잡한 문제를 풀어나갈 줄 아는 개발자의 능력에 달려 있다는 사실을 잊지 말아야 합니다. 스프링만 잘 공부하면 자바 언어 자체나 객체지향 설계와 개발 실력 따윈 별로 신경 쓰지 않아도 복잡한 엔터프라이즈 시스템을 잘할 수 있을 거라고 생각하면 오산입니다.



POJO 프로그래밍


스프링의 목적은 애플리케이션 개발의 복잡함을 줄여주는 것 또는 효과적으로 대응하게 해주는 것이라고 하면 맞는 말이긴 하지만 좀 추상적입니다. 좀 더 기술적으로 스프링이 지향하는 목적이 무엇인지 정의해보겠습니다.


스프링의 핵심 개발자들이 함께 쓴 Professional Spring Framework라는 책이 있습니다. 이 책에서 스프링 핵심 개발자들은 "스프링의 정수는 엔터프라이즈 서비스 기능을 POJO에 제공하는 것"이라고 했습니다. 엔터프라이즈 서비스라고 하는 것은 보안, 트랜잭션과 같은 엔터프라이즈 시스템에서 요구되는 기술을 말합니다. 이런 기술을 POJO에 제공한다는 말은, 뒤집어 생각해보면 엔터프라이즈 서비스 기술과 POJO라는 애플리케이션 로직을 담은 코드를 분리했다는 뜻이기도 합니다. "분리됐지만 반드시 필요한 엔터프라이즈 서비스 기술을 POJO 방식으로 개발된 애플리케이션 핵심 로직을 담은 코드에 제공한다"는 것이 스프링의 가장 강력한 특징과 목표입니다.



스프링의 핵심: POJO

스프링의 핵심이 POJO 프로그램이이라는 사실은, 스프링의 핵심을 가장 나타내고 있다고 알려진 아래 스프링 삼각형을 통해서도 잘 알 수 있습니다. 이 그림은 스프링 소스의 CTO인 아드리안 콜리어가 스프링의 핵심 개념을 설명하기 위해 만들었습니다.



스프링으로 개발한 애플리케이션의 기본 구조를 보여줍니다. 스프링 애플리케이션은 POJO를 이용해서 만든 애플리케이션 코드와, POJO가 어떻게 관계를 맺고 동작하는지를 정의해놓은 설계정보로 구분됩니다. DI의 기본 아이디어는 유연하게 확장 가능한 오브젝트를 만들어두고 그 관계는 외부에서 다이나믹하게 설정해준다는 것입니다. 이런 DI의 개념을 애플리케이션 전반에 걸쳐 적용하는 것이 스프링의 프로그래밍 모델입니다. 


스프링의 주요 기술인 IoC/DI, AOP와 PSA(Portable server abstractions)는 애플리케이션을 POJO로 개발할 수 있게 해주는 기능기술(enabling technology)이라고 불리웁니다.



POJO란 무엇인가?

스프링 애플리케이션 개발의 핵심인 POJO를 좀 더 자세히 알아보겠습니다. POJO는 Plain Old Java Object의 첫 글자를 따서 만든 약자입니다. 최근 몇년간 자바에서 유행어처럼 사용되고 있는 이 단어는 마틴 파울러가 2000년에 컨퍼런스 발표를 준비하다가 만들어낸 용어라고 합니다. 그런데 POJO라는 용어를 만들어낸 이유가 재미있습니다. 마틴 파울러는 당시 인기를 끌고 있던 EJB처럼 복잡하고 제한이 많은 기술을 사용하는 것보다는 자바의 단순한 오브젝트를 이용해 애플리케이션의 비즈니스 로직을 구현하는 편이 낫다고 생각했습니다. 그럼에도 왜 개발자는 자바의 단순한 오브젝트를 사용하길 꺼리는지 궁금했습니다. 그 이유를 찾아보니 평범한 자바오브젝트에는 EJB와 같은 그럴싸한 이름이 없기 때문이었습니다. 그래서 뭔가 있어 보이도록 만든 이름이 바로 POJO였습니다. 같은 설명이지만 그냥 "간단한 자바오브젝트를 사용하는데요"라고 말하는 것보다 "POJO 방식의 기술을 사용합니다"라고 하면 왠지 세련되고 첨단기술을 쓰는 것처럼 느껴진다는 심리를 이용한 것입니다. 평범한 자바오브젝트에 멋진 이름을 붙여줬던 시도는 기대 이상으로 성공적이었습니다.


단지 POJO라는 폼 나는 이름 때문만은 아니겠지만, 아무튼 그 이후로 POJO 프로그래밍에 관한 개발자들의 관심이 높아졌고 POJO를 지원한다는 걸 장점으로 내세우는 많은 프레임워크 기술이 쏟아져 나오기 시작했습니다. 심지어 EJB조차 3.0에서는 기존의 문제점을 반성하고 POJO 프로그래밍의 장점을 적극 도입하려고 했습니다.



POJO의 조건

그렇다면 이 POJO 프로그래밍이란 무엇일까요? 그저 EJB를 사용하지 않으면 POJO일까요? 특정 프레임워크의 클래스를 상속하지 않으면 POJO일까요? 단순하게 보자면 그냥 평범한 자바오브젝트라고 할 수 있지만 좀 더 명확하게 하자면 적어도 다음의 세 가지 조건을 충족해야 POJO라고 불리울 수 있습니다.


특정 규약(Contract)에 종속되지 않는다

POJO는 자바 언어와 꼭 필요한 API 외에는 종속되지 않아야 합니다. 따라서 EJB2와 같이 특정 규약을 따라 비즈니스 컴포넌트를 만들어야 하는 경우는 POJO가 아닙니다. 스트럿츠 1과 같이 특정 클래스를 상속해서 만들어야 하는 규약이 있는 경우도 마찬가지입니다. 특정 규약을 따라 만들게 하는 경우는 대부분 규약에서 제시하는 특정 클래스를 상속하도록 요구합니다. 그럴 경우 자바의 단일 상속 제한 때문에 더 이상 해당 클래스에 객체지향적인 설계 기법을 적용하기가 어려워지는 문제가 생깁니다. 또한 규약이 적용된 환경에 종속적이 되기때문에 다른 환경으로 이전이 힘들다는 문제점이 있습니다.


스트럿츠 1의 ActionForm을 상속한 폼 정보를 담는 객체는 스트럿츠의 규약에 독립적이어야 하는 서비스 레이어로 전달하기에 적합하지 않기 때문에 거의 비슷한 구조를 가진 DTO를 만들어 복사해줘야 하는 번거로움이 있습니다. 당연히 객체지향적인 설계로 만든 POJO 도메인 모델 오브젝트를 그대로 웹 레이어에서 사용할 수는 없습니다. EJB2 또한 비즈니스 컴포넌트가 EntityBean 클래스를 상속해야만 합니다. 별다른 가치를 주지도 못하는 규약 따위에 종속되지 않아야 하고, 객체지향 설계의 자유로운 적용이 가능한 오브젝트여야만 POJO라고 불리울 수 있습니다.


특정 환경에 종속되지 않는다

특정 환경에 종속적이어야만 동작하는 오브젝트도 POJO라고 할 수 없습니다. EJB 3는 분명 이전 버전의 문제점이던 규약에 따라 오브젝트를 만들어야 한다는 단점은 극복했습니다. 어떤 면에서 POJO에 가가운 설계와 구현이 가능해졌습니다. 하지만 여전히 JNDI라는 서버 서비스를 필요로 합니다. EJB 3 빈의 의존 오브젝트 정보는 JNDI를 통해 가져와야 하기 때문입니다. 따라서 JNDI가 없는 환경에서는 그대로 사용하기가 힘듭니다. 이렇게 JNDI와 같은 특정 환경이 의존 대상 검색 방식에 종속적이라면 POJO라고 할 수 없습니다.


어떤 경우는 특정 벤더의 서버나 특정 기업의 프레임워크 안에서만 동작 가능한 코드로 작성되기도 합니다. 또 환경에 종속적인 클래스나 API를 직접 쓴 경우도 있습니다. 예를 들면 WebLogic 서버에서만 사용 가능한 API를 직접 쓴 코드를 갖고 있거나 특정 OS에서 제공하는 기능을 직접 호출하도록 만들어진 오브젝트 등이 존재합니다. 이런 식으로 순수한 애플리케이션 로직을 담고 있는 오브젝트 코드가 특정 환경에 종속되게 만드는 경우라면 그것 역시 POJO라고 할 수는 없습니다. POJO는 환경에 독립적이어야 합니다.


특히 비즈니스 로직을 담고 있는 POJO 클래스는 웹이라는 환경정보나 웹 기술을 담고 있는 클래스나 인터페이스를 사용해서는 안됩니다. 설령 나중에는 웹 컨트롤러와 연결돼서 사용될 것이 뻔하다고 할지라도 직접적으로 웹이라는 환경으로 제한해버리는 오브젝트나 API에 의존해서는 안됩니다. 그렇게 하면 웹 외의 클라이언트가 사용하지 못하게 됩니다. 또 웹 서버에 올리지 않고 독립적으로 테스트하기도 힘들어집니다. 기술적인 내용을 담은 웹정보가 비즈니스 로직과 얽혀 있으니 이해하기도 힘들고 수정하기도 어렵습니다. 비즈니스 로직을 담은 코드에 HttpServletRequest나 HttpSession, 캐시와 관련된 API가 등장하거나 웹 프레임워크의 클래스를 직접 이용하는 부분이 있다면 그것은 진정한 POJO라고 볼 수 없습니다.


요즘은 소스코드에 직접 메타정보를 추가해주는 애노테이션을 많이 사용합니다. 그렇다면 애노테이션을 사용했을 경우에는 POJO일까 아닐까요? 이전에 XML에 담겨있던 설정정보를 자바 코드로 가져왔으니 이는 POJO가 아니라고 할지 모르겠지만 꼭 그런건 아닙니다. 애노테이션이 단지 코드로 표현하기는 적절치 않은 부가적인 정보를 담고 있고, 그 때문에 환경에 종속되지만 않는다면 여전히 POJO라고 할 수 있습니다. 하지만 애노테이션이나 엘리먼트 값에 특정 기술과 환경에 종속적인 정보를 담고 있다면 그때는 POJO로서의 가치를 잃어버린다고 할 수 있습니다.


그럼 특정 기술규약과 환경에 종속되지 않으면 모두 POJO라고 말할 수 있을가요? 많은 개발자가 크게 오해하는 것 중의 하나가 바로 이것입니다. 그저 평범한 자바 클래스를 써서 개발했다고 해서 POJO 방식으로 개발했다고 생각합니다. 단지 자바의 문법을 지키고, 순순하게 JavaSE API만을 사용했다고 해서 그 코드를 POJO라고 할 수는 없습니다. POJO는 객체지향적인 자바 언어의 기분에 충실하게 만들어져야 하기 때문입니다. 그것이 POJO라는 이름을 붙이면서까지 단순한 자바오브젝트에 집착하는 이유입니다. 자바는 객체지향 프로그래밍을 가능하게 해주는 언어이지만, 자바 언어 문법을 사용했다고 해서 자동으로 객체지향 프로그래밍과 객체지향 설계가 적용됐다고 볼 수는 없습니다.


책임과 역할이 각기 다른 코드를 한 클래스에 몰아넣어 덩치 큰 만능 클래스로 만드는 경우, 재사용이 불가능할 정도로 다른 레이어와 영역의 코드와 강한 결합을 가지고 만들어지는 경우, 상속과 다형성의 적용으로 처리하면 깔끔한 것을 if/switch문이 가득 찬 길고 긴 메소드로 작성해놓은 경우라면 과연 그것이 객체지향적인 자바오브젝트라고 할 수 있을지 의문입니다. 그런 식으로 설계되고 개발된 오브젝트라면 단지 특정 기술과 환경에 종속적이지 않다고 해서 POJO라고 부르기 힘듭니다.


진정한 POJO란 객체지향적인 원리에 충실하면서, 환경과 기술에 종속되지 않고 필요에 따라 재활용될 수 있는 방식으로 설계된 오브젝트를 말합니다. 그런 POJO에 애플리케이션의 핵심 로직과 기능을 담아 설계하고 개발하는 방법을 POJO 프로그래밍이라고 할 수 있습니다.



POJO의 장점

그렇다면 POJO 프로그래밍의 장점은 무엇일까요? 바로 POJO가 될 수 있는 조건이 그대로 POJO의 장점이 됩니다.


특정한 기술과 환경에 종속되지 않는 오브젝트는 그만큼 갈끔한 코드가 될 수 있습니다. 로우 레벨의 기술과 환경에 종속적인 코드가 비즈니스 로직과 함께 섞여 나오는 것만큼 지저분하고 복잡한 코드도 없습니다. 그런 코드는 개발하기도 힘들고, 오류를 찾고 디버깅하기는 더더욱 힘듭니다. 코드를 읽고 이해기도 어려울뿐더러 검증이나 테스트 작성에도 한계가 있으므로 유지보수는 큰 부담이 됩니다.


또 POJO로 개발된 코드는 자동화된 테스트에 매우 유리합니다. 환경의 제약은 코드의 자동화된 테스트를 어렵게 합니다. 컨테이너에서만 동작을 확인할 수 있는 EJB 2는 테스트하려면 서버의 구동 및 빌드와 배치 과정까지 필요합니다. 자동화된 테스트가 불가능한건 아니지만 매우 복잡하고 번거로우므로 대부분 수동 테스트 방식을 선호합니다. 간단한 코드 수정에도 빌드, 배치, 수동 확인이라는 번거로운 사이클을 따라 작업하기가 얼마나 힘든지는 EJB 개발자들이 경험적으로 잘 알고 있습니다. 그에 반해 어떤 환경에도 종속되지 않은 POJO 코드는 매우 유연한 방식으로 원하는 레벨에서 코드를 빠르고 명확하게 테스트할 수 있습니다.


객체지향적인 설계를 자유롭게 적용할 수 있다는 것도 큰 장점입니다. 개발자들이 자바와 객체지향 프로그래밍, 모델링과 설계에 대해 배울 때 그려봤던 도메인 모델과, 오랜 경험을 통해 쌓여온 재활용 가능한 설계 모델인 디자인 패턴 등은 POJO가 아니고는 적용하기 힘듭니다. 로드 존슨은 특정 기술과 규약, 환경보다 자바 언어와 객체지향 기술이 더 중요하다는 사실을 끊임없이 강조했습니다. 그저 로드 존슨이 객체지향 기술의 광적인 팬이기 때문일까? 아닙니다. 로드 존슨은 대규모 자바 엔터프라이즈 시스템을 개발해온 수많은 경험을 통해 자바 언어의 객체지향적인 설계와 구현 방식이야말로 그 어떤 새로운 기술과 환경, 툴보다 더 실제 프로젝트를 성공시키는 데 중요한 요소임을 알고 있기 때문입니다.


객체지향 프로그래밍은 지금까지 나온 프로그래밍 패러다임 중에서 가장 성공했고, 가장 많은 언어에서 적용됐으며, 무엇보다도 엔터프라이즈 시스템에서와 같이 복잡한 문제 도메인을 가진 곳에서 가장 효과적으로 사용될 수 있다는 사실이 이미 오랜 시간을 통해 증명되었습니다.


그렇다면 왜 이런 장점이 있는 POJO 방식, 어쩌면 가장 기초적이라고 할 수 있는, 자바의 초기부터 애용되던 이러한 POJO를 이용한 개발 방식이 왜 EJB처럼 제약이 심하고 특정 기술에 종속적인 코드를 강요하고 자바의 객체지향적인 특징을 무시해버리는 기술에 밀려버렸던 것일까요? 그것은 앞에서 살펴봤듯이 엔터프라이즈 시스템의 개발이라는 복잡한 과제에 대해 잘못된 접근 방법을 선택했기 때문입니다.



POJO 프레임워크

스프링은 POJO를 이용한 엔터프라이즈 애플리케이션 개발을 목적으로 하는 프레임워크라고 했습니다. POJO 프로그래밍이 가능하도록 기술적인 기반을 제공하는 프레임워크를 POJO 프레임워크라고 합니다. 스프링 프레임워크와 하이버네이트를 대표적인 POJO 프레임워크로 꼽을 수 있습니다. 주로 DB 이용 기술에 POJO를 적용하는 것을 목적으로 하는 하이버네이트와 달리, 스프링은 엔터프라이즈 애플리케이션 개발의 모든 영역과 계층에서 POJO 방식의 구현이 가능하게 하려는 목적으로 만들어졌습니다.


스프링을 이용하면 POJO 프로그램의 장점을 그대로 살려서 엔터프라이즈 애플리케이션의 핵심 로직을 객체지향적인 POJO를 기반으로 깔끔하게 구현하고, 동시에 엔터프라이즈 환경의 각종 서비스와 기술적인 필요를 POJO 방식으로 만들어진 코드에 적용할 수 있습니다.



스프링이 엔터프라이즈 시스템의 복잡함을 어떻게 다루는지를 보여주는 그림입니다. 하지만 자신은 기술영역에만 관여하지 비즈니스 로직을 담당하는 POJO에서는 모습을 감춥니다. 데이터 액세스 로직이나 웹 UI 로직을 다룰 때만 최소한의 방법으로 관여합니다. POJO 프레임워크로서 스프링은 자신을 직접 노출하지 않으면서 애플리케이션을 POJO로 쉽게 개발할 수 있게 지원해줍니다.



이제 스프링이 어떤 목적으로 만들어졌고, 스프링을 사용하면 개발자가 어떤 혜택을 누릴 수 있는지 어느 정도 이해할 수 있을 것입니다. 물론 객체지향적 POJO 프로그래밍을 어떻게 효과적으로 적용할지는 개발자에게 또 하나의 숙제이고 부담입니다. 이를 위해 객체지향 분석과 설계에 대한 지식을 습득하고 훈련해야 합니다. 당연히 자바 언어와 JVM 플랫폼 그리고 JDK API 사용법도 잘 알아야 합니다. 객체지향 기술의 선구자들이 잘 정리해놓은 디자인 패턴과 구현 패턴, 좀 더 나은 코드 구조를 만들기 위한 리팩토링 기술 또한 필요합니다.


스프링을 사용한다고 해서 이런 부담이 줄어드는 건 아닙니다. 대신 스프링은 개발자들이 복잡한 엔터프라이즈 기술보다는 이러한 객체지향적인 설계와 개발의 원리에 좀 더 집중할 수 있도록 기회를 줍니다. 동시에 스프링이 제공하는 기술과, 프레임워크 API 및 확장 포인트는 그것을 이용하는 코드가 자연스럽게 객체지향적인 설계원리를 따라가도록 이끌어주기도 합니다. 좋은 코드와 좋은 프레임워크의 특징은 그것을 사용해서 만들어지는 코드가 나쁜 코드가 되기 어렵다는 점입니다. 스프링은 매우 자연스럽게 개발자가 좋은 코드를 만들게 해주는 특별한 재주가 있습니다. 이게 바로 스프링이 전 세계적으로 그토록 많은 개발자에게 인정받고 인기를 누리는 이유입니다.



출처: https://12bme.tistory.com/157 [충일함만이 명확함에 이른다.]

데이터베이스(DataBase, DB)는 여러 사람들이 공유하고 사용할 목적으로 통합 관리되는 정보의 집합이다. 은행, 예약, 검색, 쇼핑 등 일상 속에서 이용하고 있는 많은 온라인 서비스들에서 DB를 활용한다. 과거에는 숫자와 문자 정보 중심의 DB가 많이 쓰였으나 시간이 지남에 따라 멀티미디어 데이터까지도 관리하는 수준으로 발전하였다. (라고는 하지만 blob 데이터를 DB에 두는 것 외에는 실제로 일을 하면서 데이터베이스에 멀티미디어 데이터를 관리하는 것을 본 적은 없다.)

DB는 반드시 데이터베이스 관리 시스템(DataBase Management System, DBMS)과 함께 한다. DB 자체는 관련성 있는 데이터의 모음이고, 실제로 우리는 그 모음을 잘 다룰 수 있도록 도와주는 시스템인 DBMS를 통해 DB를 사용한다. 관계형 DB(Relational DataBase, RDB)를 관리해주는 RDBMS가 오랜 기간 동안 시장을 지배하고 있고, 차세대 DB로 NoSQL DB가 쓰이고 있다. 

이 글에서는 전통적인 DB 강의에서 주로 다루는 RDB를 위주로 쓰여있다. DB와 DBMS를 사용하는 측면에서의 이야기를 진행한 뒤, 사용을 넘어서 DB를 만들기 위해서는 어떤 것들을 알아야 하는지에 대해 살펴볼 것이다.



데이터베이스 사용 관점 개요

DB는 관련 있는 데이터를 모아놓은 것이다. 실제 데이터와, 그 데이터에 대한 정보를 모아 놓은 메타 데이터로 이루어져 있다. 말하자면 데이터들을 모아놓은 파일과 같은 것이다. 어떤 경우에는 복잡하게 DB를 사용하지 않고 직접 파일로 관리하는 것이 더 편할지 모르지만, 많은 경우에 데이터를 효율적으로 관리하거나 접근 관리 등 여러 가지 추가적인 기능들이 필요하기 때문에 DB를 쓰게 된다.

DB에서 원하는 데이터를 가져올 때는 직접 DB에 접근하지 않고 DBMS에게 질의(Query)하게 된다. 컴퓨터 프로그램을 만들기 위해서 프로그래밍 언어가 있듯 DBMS에게 질의하기 위해 SQL이라는 언어를 사용한다. DBMS는 입력된 SQL을 처리하고 필요하다면 DB에 접근하여 작업을 수행한다.

대부분의 RDB에서는 질의를 처리하는 것 외에도 View, 데이터 무결성 제약(Data Integrity Contraints), Trigger, 권한 관리(Authorization) 등 기능을 제공한다. 사용자는 이러한 기능들을 바탕으로 DB를 좀 더 편리하게 이용할 수 있게 된다.

DB는 구조화된 데이터를 저장한다. 따라서, 현실 세계의 데이터를 DB에 저장하기 위해 모델링을 해야 한다. RDB에서는 이름에도 관계형이라는 말이 들어가듯 보통 개체-관계 모델링(Entity-Relationship Modelling, ERD) 방법으로 데이터를 구조화한다. 이러한 모델링 작업을 DB 설계라고 한다.

중복된 데이터가 존재하면 DB를 관리하는 과정에서 이상이 생길 수 있다. DB를 설계할 때 이러한 문제가 아예 일어나지 않게끔 한다면 더 좋을 것이다. 함수 종속성을 확인하면 어떤 이상(Anomaly)이 존재할지 알 수 있으며, 함수 종속성을 없애기 위해 구조를 변경하는 작업을 정규화(Normalization)이라고 한다. 과도한 정규화는 성능 문제를 야기할 수 있기 때문에 상황에 따라 적절한 타협을 해야 하는 경우도 생길 것이다.



데이터베이스 스키마

데이터베이스 스키마(Database schema)는 DB에서 데이터의 구조, 데이터의 표현 방법, 데이터 간의 관계를 형식 언어로 정의한 구조이다. 널리 쓰이는 3단계 스키마 구조에서는 DB를 관점에 따라 외부 스키마, 개념 스키마, 내부 스키마 3단계로 나뉜다. 외부 스키마에서는 각 사용자의 관점에 대해 보는 것이고, 개념 스키마에서는 모든 사용자의 관점으로 보는 것이며, 내부 스키마는 물리적으로 DB에 접근하는 관점으로 보는 것이다. 이는 추상화와 비슷한데, 각 단계를 분리함으로써 논리적/물리적 독립성을 얻게 된다. 좀 더 알고 싶다면 다음 링크를 참고.

출처 : https://www.spinellis.gr/etech/db/abstr.htm



관계형 데이터 모델

관계 모델에서는 이론적으로 DB를 관계(Relation)의 집합으로, 관계를 행(Tuple)의 집합으로, 행을 속성(Attribute)의 집합으로 본다. RDB에서는 관계가 테이블(Table), 행이 레코드(Record), 속성이 컬럼(Column)으로 대치된다. 관계라는 이름으로 명명된 이유는 속성과 행이 어떤 관계에 의해 모여진 집합이기 때문이라는 추측이 있다. 관계에 있는 속성의 개수를 차수(Degree), 행의 개수를 Cardinality라고 한다. 예를 들어 유저 테이블에 id, password, email, created_date 4개의 컬럼과 2만 개의 레코드가 존재한다면 차수가 4, cardinality는 20000이 되는 것이다.

출처 : 위키피디아

관계형 데이터 모델에서 속성은 해당 속성이 가질 수 있는 모든 값에 대한 도메인을 가지며, 원자적이어야 한다. 원자적이라는 뜻은 더 이상 쪼개어질 수 없다는 뜻이다. 예를 들어 숫자 속성은 더 이상 쪼개질 수 없는 원자적인 속성이지만 집합이나 리스트와 같은 데이터 모음에 대한 속성과 여러 속성을 합친(Composite) 속성은 원자적이라고 볼 수 없다. 속성에는 null이라는 특별한 값이 허용된다. 그리고, 관계 간에는 순서가 없고, 행 간에도 순서가 없다(Unordered). 즉, 물리적으로 1000번째 레코드 다음에 1001번째 레코드가 존재하지 않을 수도 있다는 뜻이다.

RDB에서는 관계의 집합과 제약 조건의 집합으로 이루어져 있다. 제약 조건으로는 키 제약 조건(Key constraint), 개체 무결성 제약 조건(Entity integrity constraint), 참조 무결성 제약 조건(Referential integrity constraint) 등이 있다. 

키는 슈퍼 키(Super key), 후보 키(Candidate key), 기본 키(Primary key)로 나뉜다. 슈퍼 키는 그 관계의 모든 행을 유일하게 식별할 수 있도록 하는 속성의 집합이다. 모든 속성을 모은 것을 슈퍼 키라고 할 수도 있지만, 가능하면 적은 개수의 속성으로 키를 구성하는 것이 행을 식별하는 데 이점이 있다. 그렇기 때문에 후보 키라는 개념이 있으며, 후보 키는 슈퍼 키 중 가장 속성의 개수가 적은 것이다. 예를 들어 슈퍼 키 A는 속성이 4개, 슈퍼 키 B는 속성이 2개, 슈퍼 키 C는 속성이 2개라면 B, C가 후보 키가 될 조건을 만족한다. 그중에서도 하나의 후보 키가 기본 키로 선택되고, 모든 관계는 단 하나의 기본 키만을 가진다.

참조 무결성 제약 조건은 RDB에서 매우 중요한 제약 조건 중 하나이다. 흔히 외래 키(Foreign key)로 알려진 키에 적용되는데, 외래 키의 대상 관계에 그 키를 가진 행이 반드시 존재해야 한다는 제약 조건이다. 이 무결성 제약 조건으로 관계있는 데이터가 반드시 DB에 존재한다는 보장을 할 수 있으므로 매우 유용하다.



관계 대수

관계형 데이터 모델에서 데이터 취급을 위한 연산 체계로 관계 대수(Relational algebra)가 사용된다. 관계 대수의 연산은 실제 SQL 언어에서의 연산과 대치되며, DBMS가 질의 처리를 하기 전 질의를 관계 대수로 먼저 인식한다. 관계 대수는 절차적 언어로 select, project, union, set difference, cartesian product, rename 6 가지 기본 연산으로 이루어져 있다. 이 연산들에 더해서 흔한 질의 표현의 단순화를 위해 대입, 교집합, 자연 조인, 세타 조인, 동등 조인, 외부 조인, 나누기 연산 등을 추가로 정의하여 사용한다. 단, 이러한 추가적인 연산은 연산 능력을 더 키우지는 않는다. 각 연산은 하나 혹은 두 관계를 입력으로 받아 새로운 관계를 결과로 낸다.

관계 대수에 대해서 이 글에서 설명하기는 힘들고(특히, 브런치에서는 수식 입력이 어렵다...), 링크해 둔 위키피디아 페이지가 잘 설명하고 있으니 참고하기 바란다.



SQL(Structured Query Language)

RDBMS를 통해 DB를 사용하기 위해 사용자는 SQL이라는 특수 목적의 프로그래밍 언어를 사용하여 DBMS에게 질의해야 한다. 앞서 관계 대수는 SQL의 연산으로 대치될 수 있다고 하였다. 관계 대수의 연산은 보통 "SELECT ~ FROM ~ WHERE" 식의 질의에 대치된다. 이는 데이터 조작 언어(Data Manipulation Language, DML)에 해당되는 기능이고, SQL은 크게 DML 외에도 데이터 정의 언어(Data Definition Language, DDL)데이터 제어 언어(Data Control Language, DCL) 세 가지로 나뉜다.

데이터 정의 언어는 테이블이나 색인(Index), 제약 조건 등을 생성하거나 삭제, 수정하는 작업에 대한 언어이다. 앞서 RDB에서 관계는 테이블로 나타난다고 하였다. RDBMS는 테이블에서 데이터를 잘 찾기 위해 색인이라는 것을 만들고, 제약 조건들을 통해 데이터를 안정적으로 운영한다. 

데이터 조작 언어는 DB에 대해 데이터 검색, 추가, 삭제, 갱신을 위한 언어이다. SELECT, INSERT, DELETE, UPDATE로 시작하는 SQL이 데이터 조작 언어에 포함된다.

데이터 제어 언어는 데이터에 대한 접근 권한을 제어하기 위한 언어이다. DBMS는 여러 유저에 의해 사용되는 소프트웨어이고, 보안 등의 목적을 위해 사용자에 따라 어떤 데이터에는 접근 가능하고, 어떤 데이터에는 접근 불가능하게 한다. 예를 들어, 애플리케이션에 대한 데이터만 관리하면 되는 사용자에게는 굳이 시스템에 대한 데이터에 접근할 수 없도록 하면 보안상으로 이점이 있을 것이다. 이는 최소 권한의 원칙(The principle of least privilege)과 리눅스의 다중 사용자 관점에서도 일맥상통하는 바가 있다.

DB를 다루기 위한 모든 작업에 SQL이 사용되기 때문에 당연히 사용자는 SQL을 잘 알아야 할 것이다. DBMS는 입력된 SQL을 최적화하기 위해 노력하지만 최적화에는 한계가 있기 때문에 어떤 SQL을 실행하느냐에 따라 질의 수행 속도가 천차만별로 차이나기도 한다. SQL에 대한 내용은 정말 방대하기 때문에 이 글에서 다루기 어렵다. 기본적인 SQL 학습에 대해서는 W3Schools의 튜토리얼을 추천한다. SQL에 대한 표준이 지정되어 있긴 하지만, 시스템에 따라서 세세한 부분의 차이가 있고, 또 제공하는 추가 기능들이 다르기 때문에 사용하려는 시스템의 매뉴얼을 잘 살펴봐야 할 것이다.



View

View는 테이블을 수정하지 않고 데이터를 보는 관점을 새롭게 정의하는 것으로, 논리적 테이블이라고 생각할 수 있다. 예를 들어, 사용자 테이블에 대해 특정 나이 이상의 사용자만을 보여주는 View를 정의하면 그러한 사용자들에 대한 데이터를 좀 더 편리하게 조회할 수 있을 것이다. DBMS는 여러 사용자가 함께 사용하기 때문에 실제 테이블이 어떻게 생겼는지는 숨기고 View만을 사용자에게 허용함으로써 보안을 추구할 수도 있다. 실질적으로 View는 다른 관점에서의 데이터를 보여주는 것일 뿐, 물리적으로 존재하는 테이블은 아님을 알아둬야 할 것이다.

반면, 실체화 뷰(Materialized View)는 자주 사용되는 View의 데이터를 따로 저장해둠으로써 질의 처리 속도를 향상할 수 있다. 즉, 실체화 뷰의 데이터는 물리적으로 존재한다. 말하자면 데이터를 캐시 해두는 것과 비슷하다. 



무결성 제약 조건(Integrity Constraint)

무결성 제약 조건을 이용하면 DB 설계 단계에서 잘못된 데이터가 DB에 입력되고 유지되는 것을 막을 수 있다. 제약 조건은 SQL, 데이터 정의 언어에 의해 추가, 삭제, 수정된다. 널리 사용되는 무결성 제약 조건으로는 NOT NULL, UNIQUE, PRIMARY KEY, FOREIGN KEY, CHECK가 있다. NOT NULL과 UNIQUE 제약 조건은 이름 그대로 어떤 값이 null이 아니어야 하는 제약 조건, 해당 관계 내에서 유일해야 한다는 제약 조건을 거는 것이다. PRIMARY KEY는 앞서 언급한 키 제약 조건에 해당되고, FOREIGN KEY는 외래 키로 참조 무결성 제약 조건을 만족시켜주기 위한 제약 조건이다. CHECK 제약 조건을 사용하면 어떤 데이터가 조건을 만족해야만 하도록 제한할 수 있다. (사실 NOT NULL 제약 조건은 CHECK 제약 조건의 하위 개념으로 볼 수 있다.)

외래 키에 대해서는 참조 조작이라 불리는 편의 기능이 제공되기도 한다. 자세한 내용은 외래 키의 링크된 위키피디아 문서를 참고.



Trigger

Tigger는 DB 관리의 편의를 위한 기능이다. 크게 Row level trigger, Statement level trigger로 나뉜다. Trigger의 선언과 삭제 또한 SQL로 이뤄진다. 예를 들어, 테이블에 새로운 데이터가 입력될 때 특정 컬럼의 값을 현재 시각으로 지정하거나, 행에 변화가 일어났을 때 어떤 작업을 수행해줘야 하는 상황 등에 유용하게 쓰일 수 있다. 좀 더 자세한 설명은 링크를 참고.



권한 관리

DBMS는 여러 사용자에 의해 이용되는 시스템이다. 그렇기 때문에 사용자별 권한을 관리할 수 있도록 기능을 제공해주는 것이 필요하다. 마치 리눅스에서 여러 사용자로 로그인하여 시스템을 이용할 수 있는 것과 비슷하다. 실제로 DBMS를 사용할 때는 사용자로서 로그인하여 질의를 실행할 수 있게 된다. 권한 관리에 의해서 시스템 관리자는 사용자마다 실행 가능한 질의를 제한할 수 있다. 관리할 수 있는 다양한 권한에 대해서는 다음 문서를 참고. 시스템마다 차이는 있지만 대체로 비슷하다.



개체-관계 모델(Entity-Relationship Model)

DB를 잘 설계하는 것은 매우 중요하다. 잘 설계된 DB는 관리되기 쉽고, 질의 수행 속도도 빨라질 수 있다. 서버의 병목 현상을 일으키는 요인이 대부분 DB 접근임을 생각하면 DB 설계를 잘 하는 것이 중요하다는 것을 더 느낄 수 있다.

관계형 데이터 모델을 사용하는 RDB에서는 데이터 구조화를 위해 개체-관계 모델로 모델링한다. 개체는 분리된 사물 하나하나에 대한 것으로, 관계는 두 개 이상의 개체들이 어떻게 서로 연관되어 있는지에 대한 정보이다. 개체-관계 모델링 산출물로 개체-관계 다이어그램이라는 그림을 만든다. 설계한 DB를 다이어그램으로 나타낸 것인데, 이 다이어그램을 그리는 방법도 여러 가지가 존재하며, 크게 전통적인 방법과 크로우즈 핏 방법으로 나뉜다. 또한, 개체-관계 다이어그램을 그리기 위한 툴들도 상용으로 많이 존재하며 쓰인다.

출처 : 위키피디아

개체-관계 모델에서는 대개 각 개체를 위한 테이블과 관계를 위한 테이블들을 만드는 것으로 구현되며, 개체-관계 다이어그램이 만들어지면 설계 그대로 RDB로 옮길 수 있다. 관계에는 일대일 관계, 일대다 관계, 다대다 관계가 있다. 보통 일대일 관계는 개체에 대한 테이블에서 외래 키를 직접 두는 것으로, 일대다 관계와 다대다 관계는 관계를 위한 테이블을 별도로 선언하여 해당 테이블에서 개체 테이블에 대한 외래 키를 두는 것으로 구현된다.

개체-관계 모델링에 대한 자세한 자세한 내용은 링크해 둔 위키피디아 문서를 참고하길 바란다. 잘 모델링하기 위한 방법은 또 다른 방대한 분야이며, 다음에 설명할 내용인 정규화에서 일부 소개될 것이다.



정규화(Normalization)

RDB를 설계할 때 신경 쓰지 않으면 데이터의 중복이 많아진다. 정규화를 거치면 크고, 제대로 조직되지 않은 테이블들을 작게 나누면서(Decomposition) 데이터의 중복을 없애고 잘 조직된 구조로 만들어지게 된다. 그전에, 잘 조직되지 않은 테이블에서 일어날 수 있는 이상(Anomaly)들을 먼저 알아보기 위해 아래 테이블을 보자.

학번, 과목코드, 이름, 연락처
1601, C01, 강하늘, 010-1234-0000
1601, C02, 강하늘, 010-1234-0000
1602, A01, 서현진, 010-3210-9999
1603, B01, 최유정, 010-5555-1234
1603, C01, 최유정, 010-5555-1234

위 테이블에서는 대표적 이상인 삽입 이상(Insert Anomaly), 삭제 이상(Delete anomaly), 갱신 이상(Update Anomaly) 모두 나타난다. 삽입 이상은 관계에 데이터를 삽입할 때 의도와 상관없이 원하지 않는 값들도 함께 삽입하게 되는 현상이다. 위 테이블에서는 학생의 과목 정보 없이 연락처만을 추가하고 싶더라도 과목코드를 반드시 입력해야만 한다. 삭제 이상은 관계에 데이터를 삭제할 때 의도와 상관없는 값들도 함께 삭제되는, 연쇄 삭제가 일어나는 현상이다. 위 테이블에서는 학번 1602의 서현진 학생의 A01 과목을 취소하기 위해서는 해당 행을 삭제해야 하는데, 그럴 경우 연락처 정보까지도 잃게 된다. 갱신 이상은 관계의 행에 있는 속성 값을 갱신할 때 일부 행의 데이터만 갱신되어 데이터에 모순이 생기는 현상이다. 위 테이블에서는 최유정 학생이 연락처를 바꾸려 할 때 과목코드 B01, C01 모두에 대해 연락처가 수정되어야 하지만 실수로 하나의 행에서만 갱신이 일어날 수 있다. (예시 참고자료 : http://it-ing.tistory.com/50)

정규화를 거치면 위의 이상들이 일어나지 않도록 할 수 있다. 정규화는 단계적으로 적용되어 제 1 정규형(1NF), 제 2 정규형(2NF), 제 3 정규형(3NF), 보이스-코드 정규형(BCNF), 제 4 정규형(4NF), 제 5 정규형(5NF), 마지막으로 2002년에 소개된 제 6 정규형(6NF)을 만들게 된다. 정규화를 위해서는 먼저 함수 종속성을 알아야 한다. 여러 가지 법칙이 있지만, 정규화를 위해 알아둘 점은 어떤 속성에 의해 다른 속성의 값이 결정되는 속성이라는 것이다. 예를 들어, 직원 테이블이 직원 ID 속성과 직원 생일 속성을 가질 때 직원 생일 속성은 적원 ID 속성에 함수 종속(직원 ID → 직원 생일)이다. 후에 설명할 내용은 함수 종속성의 다른 속성들도 알아야 하므로 링크된 문서를 먼저 읽어보길 바란다.

현실적으로 과도한 정규화는 DB 질의 처리 속도를 떨어뜨리기 때문에 제 3 정규형 혹은 보이스-코드 정규형까지만 정규화를 진행한다. 제 3 정규형만 해도 많은 조인 연산을 요구하게 되기 때문에 제 2 정규형으로 비 정규화시키기도 한다. 따라서, 이 글에서는 제 4 정규형 일부까지만 다루고 넘어간다.



제 1 정규형

제 1 정규형은 테이블의 각 속성이 원자적이어야 한다. 이는 요즘 쓰이는 RDBMS에서 테이블을 만들 때 각 컬럼이 원자적인 속성일 것을 강제하기 때문에 지켜지지 않는 경우는 거의 없다. 또한, 기본 키로 각 행을 식별할 수 있어야 한다. 마지막으로 제 1 정규형을 만족하기 위해서는 중복되는 항목이 없어야 한다는 조건이 있다. 위키피디아 페이지에서도 설명하듯이 이 정의는 조금 모호하다. 크게 행을 가로지르며 중복되는 항목과 행 내에서의 중복되는 항목이 없도록 해야 한다는 조건이 있다. 

왼쪽이 행을 가로지르며 중복되는 항목, 오른쪽이 행 내에서의 중복되는 항목에 대한 예시 테이블이다. 딱 봐도 행을 가로지르며 중복되는 항목은 전화번호를 여러 개 두기 어렵다는 것뿐만 아니라 질의를 어렵게 하고, 행 내에서의 중복되는 항목은 속성의 의미가 모호해진다. 제 1 정규형을 만족하기 위해서는 테이블을 분해하여 다음과 같이 만들어야 한다.

출처 : 위키피디아

이 디자인에서는 전화번호들의 중복되는 항목이 나타나지 않는다. 실제로 질의하여 전화번호를 함께 조회할 때는 조인 연산을 이용한다.



제 2 정규형

제 2 정규형을 만족하기 위해서는 후보 키 전체로 후보 키에 속하지 않는 속성을 결정할 수 있어야 한다. 따라서, 하나의 속성으로만 후보 키로 사용할 경우 제 2 정규형을 만족한다. 이 조건만 두고 봤을 때는 직관적이지 않지만, 아래 예를 들어 제 2 정규형을 만족시키면서 갱신 이상을 없애는 것을 보이겠다.

출처 : 위키피디아

위 테이블은 종업원과 기술 두 속성을 모두 사용해야 후보 키로 쓸 수 있다. 즉, 후보 키는 {종업원, 기술}이다. 그러나 근무지 속성은 종업원 속성에만 종속되어 있으며, 근무지 속성은 중복된다. 이 테이블에서는 Jones의 근무지를 갱신할 때 Typing, Shorthand에 대한 근무지만 갱신될 수 있는 위험이 있다. 

출처 : 위키피디아

제 2 정규형을 만족시키기 위해 기존의 테이블을 분해한 예이다. 이 구조에서는 갱신 이상이 발생하지 않는다. 그러나 제 2 정규형을 만족시키는 모든 테이블이 갱신 이상이 없는 것은 아니다. 아래 예는 제 2 정규형을 만족하지만 갱신 이상이 발생한다.

출처 : 위키피디아

{대회, 연도}로 우승자 속성과 우승자 생년 월일 속성이 결정되지만 우승자 생년 월일은 중복된 데이터가 존재할 수 있으므로 갱신 이상이 발생할 위험을 갖고 있다. 이 문제의 원인은 우승자 생년월일 속성이 가지는 추이 종속성이다. 우승자 생년월일 속성은 우승자 속성에 의해서 결정되는데, 우승자 속성은 키 {대회, 연도}에 의해서 결정되기 때문이다. 제 3 정규형을 만족시키면서 이 문제가 사라질 수 있다.



제 3 정규형

제 3 정규형을 만족하기 위해서는 당연히 제 2 정규형을 만족하면서 동시에 모든 속성이 기본 키에 대해서만 의존되어야 한다. 이 조건은 자연히 추이 종속성이 발생하지 않을 것을 요구한다. 각 속성이 기본 키에 의해서만 결정되어야 하기 때문이다.

출처 : 위키피디아
출처 : 위키피디아

각 테이블은 기본 키에 의해서만 모든 속성이 결정된다. 대회 우승자 테이블은 {대회, 연도}가 기본 키이고, 우승자 생년 월일 테이블은 {우승자}가 기본 키이다. 제 3 정규형에서는 갱신 이상이 덜 발생한다.



보이스-코드 정규형과 제 4 정규형

보이스-코드 정규형은 강한 제 3 정규형이라고도 불린다. 제 3 정규형은 갱신 이상이 덜 일어나게 하지만, 완전히 갱신 이상이 일어나지 않게 하는 것은 아니다. 사실 보이스-코드 정규형도 이상이 존재하지 않도록 하는 정규형은 아니다. 이상이 존재하지 않는 정규형은 도메인-키 정규형으로 알려져 있으나, 도메인-키 정규형을 만드는 구체적인 방법이 밝혀지지 않았다. 

보이스-코드 정규형을 만족하기 위해서는 모든 결정자가 후보 키여야 한다. 후보 키는 모든 행을 식별할 수 있는 최소의 속성 집합이라는 점을 상기하자. 좀 더 자세한 분석은 링크된 문서를 참고하길 바란다. 

제 4 정규형은 다치 종속성이 없을 것을 요구한다. 다치 종속성이 있을 경우 데이터는 중복되기 때문에 이를 없애고자 하는 것이다. 그러나 앞서 언급한 바와 같이 현실적으로 제 4 정규형 이상까지 정규화하는 일은 잘 일어나지 않는다. 언제나 그렇듯이 개발이라는 일은 여러 가지를 복합적으로 고려해야 하는 일이고, 정규화 외에도 관심을 둬야 할 일들이 많기 때문에 우선순위가 밀린다. 현실적으로 제 3 정규형 정도까지만 만족해도 큰 이상 없이 DB를 운영할 수 있으며, 성능 등의 문제에 의해 오히려 비정규화 작업을 거친다. 다만, 다치 종속성에 의해 데이터 중복이 일어날 수 있다는 것을 인지하고 있는 것이 도움은 될 것이라 생각한다.



데이터베이스 사용 관점 정리

지금까지 관계형 데이터베이스를 사용하는 측면에서 알아야 할 것들에 대해 살펴보았다. DB는 3단계 스키마로 계층적 분리를 통해 관리의 효율성을 도모한다. DB는 관리 시스템인 DBMS에 의해 관리된다. 사용자는 DBMS를 거쳐 DB를 사용하며, DBMS는 여러 사용자들이 함께 사용하는 시스템이다. 

관계형 데이터 모델은 DB에서 데이터를 나타내기 위해 많이 사용되는 모델로, 관계형 데이터 모델을 사용하는 DB를 관계형 데이터베이스라고 한다. RDB는 관계와 제약 조건의 집합이다. 관계에 대한 연산을 관계 대수라는 연산 체계로 다룬다.

사용자는 DBMS를 이용하기 위해 SQL이라는 언어를 사용하여 질의한다. SQL은 데이터 정의 언어, 데이터 조작 언어, 데이터 제어 언어로 나뉘며, 데이터 조작 언어 부분은 관계 대수와 대치된다. 또한, DBMS는 사용자가 좀 더 편리하게 DB를 사용할 수 있도록 View, 무결성 제약 조건, Trigger, 권한 관리 등의 기능을 제공한다.

관계형 데이터 모델로 현실 세상의 문제를 모델링하기 위해 개체-관계 모델링 방법을 많이 쓴다. 개체-관계 모델링 작업 후에는 개체-관계 다이어그램이 만들어지고, 이 다이어그램 그대로 RDB에 구현될 수 있다. DB에 중복된 데이터가 많다면 잘 관리되기 어렵다. 중복된 데이터가 없고 관계들이 잘 나뉠 수 있도록 하기 위해 정규화 작업을 거친다. 현실적으로 제 3 정규형 또는 보이스-코드 정규형을 만족하는 수준까지만 정규화하며, 필요에 따라 비정규화 작업을 하기도 한다.



데이터베이스 개발 관점 개요

지금부터는 데이터베이스를 만들기 위해서 고려해야 할 점들에 대해 알아본다. 데이터베이스에서 주요 주제를 살펴보면 트랜잭션 관리 및 동시성 제어, 복구, 저장 장치, 색인, 질의 처리가 있다. 트랜잭션은 DB 사용에 있어 중요한 4가지 요구사항을 만족시켜주는 기능이고, DBMS는 여러 사용자에 의해 이용되므로 트랜잭션들을 동시에 잘 실행하기 위한 고민이 필요하다. 시스템은 언제든지 잘못될 수 있고, 심지어 트랜잭션 실행 도중 문제가 발생할 수도 있다. 에러가 아예 발생하지 않도록 하는 것은 불가능하기 때문에 에러가 발생했을 때 잘 복구할 수 있도록 하는 것이 중요하다. 

데이터는 저장 장치에 기록된다. 항상 저장 장치에 접근하는 것이 성능 저하의 가장 큰 요인이기 때문에 어떻게든 저장 장치를 최대한 잘 이용할 수 있기 위한 고민이 필요하다. 또한, 방대한 데이터를 저장하여 사용하기 때문에 데이터를 잘 찾을 수 있도록 도와주는 색인이 꼭 필요하다. 널리 쓰이는 3가지 색인 구현에 대해 알아볼 것이다. 마지막으로 DBMS에서 질의 처리를 위해 거치는 과정에 대해 알아보면서 실제로 질의가 어떻게 실행되는지에 알아볼 것이다.



트랜잭션(Transaction)

상용 DB 시스템들은 대부분 트랜잭션 기능을 제공한다. 트랜잭션이 무엇인지 설명하는 여러 가지 방법이 있겠지만, 트랜잭션은 하나의 논리적 작업이라고 볼 수 있다. 예를 들어, 은행 시스템에서 송금이라는 작업을 한다면 최소한 한 사람의 예금을 감소시키고 다른 한 사람의 예금을 증가시켜야 할 것이다. 만약 송금자의 예금을 감소시키기만 하고 시스템에 문제가 발생하여 상대방의 예금이 증가하지 않는다면 큰 문제가 발생할 것이 자명하다. 그렇기 때문에 실제로는 여러 연산으로 이루어진 작업이지만, 사용자들은 이것을 하나의 작업으로 보고 싶어 한다. 또한, DBMS는 여러 사용자에 의해 사용되는 시스템이기 때문에 여러 사용자들의 트랜잭션을 동시에 수행해야 성능 문제가 발생하지 않을 것이다. 그러나 여러 트랜잭션이 동시에 실행되는 것은 운영 체제의 임계 영역처럼 문제가 발생할 여지를 만들게 된다. 따라서, 트랜잭션에 대해 주요 이슈는 크게 트랜잭션 실행 중 장애가 일어나는 것에 대한 이슈와 여러 트랜잭션을 동시 실행에 대한 이슈 두 가지로 나뉜다.

SQL로 작성되는 트랜잭션은 매우 복잡하기 때문에 앞으로 트랜잭션 내의 연산을 읽기, 쓰기로만 단순화한 모델로 설명한다. 실제로 모든 연산은 읽기, 쓰기로만 이루어지므로 계산 능력에 있어서 차이는 없다.



트랜잭션의 성질과 상태

트랜잭션은 DB의 무결성을 지키기 위해 ACID 성질을 가져야 한다. 각 성질의 앞 글자를 딴 것으로 자세한 성질은 다음과 같다.

원자성(Atomicity) : 트랜잭션 내의 모든 연산이 적용되거나, 아무것도 적용되지 않아야 한다.

일치성(Consistency) : 트랜잭션 수행 전 DB가 무결한 상태였다면 트랜잭션 수행 후에도 DB는 무결한 상태여야 한다.

고립성(Isolation) : 여러 트랜잭션이 동시에 실행되고 있어도 사용자는 자신의 트랜잭션만 실행되고 있다고 느껴야 한다.

지속성(Durability) : 완료된 트랜잭션의 결과는 시스템 장애가 발생하더라도 DB에 반영되어야 한다.

송금을 예로 들어 다시 생각해보자. 한 사람의 예금을 감소시키고 다른 사람의 예금을 증가시키는 논리적 작업은 모두 적용이 되거나, 아니면 아예 하나도 적용되지 않아야 한다. 정상적으로 모든 작업이 적용됐다면 문제가 없는 것이고, 만약 아무 연산도 적용되지 않았다면 다시 트랜잭션을 실행하면 될 것이다. 트랜잭션 내에서는 연산 도중 DB의 무결성이 깨지는 상황이 올 수 있다. 하지만 당연히 연산이 끝난 뒤에는 DB의 무결성 제약들을 모두 만족해야만 할 것이다. 은행 시스템은 여러 사용자들의 요청을 처리해야 한다. 여러 트랜잭션이 동시에 실행될 것이지만, 각 트랜잭션 간의 영향은 없어야 한다. 더불어 앞서 트랜잭션 실행 도중에는 무결성을 만족하지 않는 상태가 될 수 있다고 하였다. 이 상황에서 고립성을 지키지 않고 무결성이 깨진 상태의 값을 이용한 계산은 잘못된 계산 결과를 초래하게 될 것이다. 마지막으로 당연히 성공적으로 완료된 트랜잭션은 후에 에러가 발생하여도 문제가 없어야 한다. 만약 송금을 한 다음 날 시스템 장애로 인해 송금 사실을 고객에게 확인해줄 수 없다면 큰 문제가 발생할 것이다.

트랜잭션의 상태는 수행 중(Active), 부분 완료(Partially committed), 완료(Committed), 실패(Failed), 철회(Aborted) 상태로 나뉜다. 정상적으로 수행 중인 트랜잭션이 모든 문장을 실행하고 나면 부분 완료 상태가 되며, 안전한 저장 장치에 로그 쓰기 작업 등이 끝나고 나면 완료 상태가 된다. 그러나 도중 트랜잭션 실행 도중 문제가 발생한다면 실패 상태가 되고, 복구해야 할 것들을 복구한 뒤 철회 상태가 된다. 또는, 트랜잭션 실행 도중 사용자 요청에 의해 실행 취소가 되기도 한다.

출처 : https://www.tutorialspoint.com/dbms/dbms_transaction.htm



직렬 가능성(Serializability)

컴퓨터 자원을 효율적으로 사용하기 위해 트랜잭션들을 동시 실행하고 싶다. 이는 운영체제의 멀티프로그래밍과 비슷하다. 그러나 DB에서는 올바른 트랜잭션 동시 실행이 무엇인가에 대해 결정해야 한다. 다른 트랜잭션의 연산과 혼합되어 동일한 DB에 작업할 것이기 때문이다. 당연히 트랜잭션 하나하나를 순서대로 실행하는 것은 문제를 일으키지 않는 올바른 순서지만 이는 동시 실행이라 보기 어렵다. 여러 트랜잭션을 동시에 잘 실행하기 위한 것을 동시성 제어라고 한다.

트랜잭션을 올바르지 않게 실행할 경우 발생하는 문제는 Dirty read, Lost update, Unrepeatable read 세 가지가 있다. Dirty read는 아직 완료되지 않은 값을 읽는 문제, Lost update는 어떤 트랜잭션에서 수정한 값을 다른 트랜잭션에서 수정해버리면서 이전의 갱신을 잃게 되는 문제이다. Unrepeatable read는 한 트랜잭션이 처음 읽은 값과 나중에 다시 읽었을 때의 값이 달라지는 문제로 트랜잭션 입장에서는 어떤 값을 읽을 때마다 다른 값을 읽을 수도 있게 되는 문제이다. 앞으로 고민하고자 하는 동시성 제어는 위 세 가지 문제를 방지하는 방법이다.

우리의 모델에서는 트랜잭션 내에 읽기, 쓰기 연산 여러 개가 나열되어 있다. 각 트랜잭션의 연산들을 순서대로 실행한 것을 스케쥴(Schedule)이라고 하자. 각 트랜잭션을 순서대로 실행한 것을 직렬 스케쥴(Serial schedule)이라고 한다. 당연히 매우 많은 스케쥴이 만들어질 수 있으나, 앞서 언급한 세 가지 문제가 없는 스케쥴을 찾는 것이 우리의 목표이며, 직렬 스케쥴과 실행 결과가 동일한 스케쥴은 문제가 없는 스케쥴로 직렬 가능 스케쥴(Serializable schedule)이라고 한다. 직렬 가능 스케쥴은 직렬 스케쥴 중 단 하나와만이라도 동일한 결과를 보이면 된다.

물론 다양한 직렬 가능 스케쥴이 존재하겠지만 직렬 가능 스케쥴을 빨리 찾을 수 있어야 할 것이다. 직렬 스케쥴과 동일함을 정의하는 방식으로 충돌 직렬 가능성과 뷰 직렬 가능성이 있다. 



충돌 직렬 가능성(Conflict serializability)

충돌(Conflict)이 무엇인지 먼저 알아보자. 동일한 데이터에 대해 두 연산이 이뤄질 때, 최소 하나의 연산이 쓰기 연산이면 두 연산은 충돌한다고 한다. 동일한 데이터가 아니면 충돌이 아니고, 두 연산이 모두 읽기 연산이어도 충돌이 아니다. 운영 체제에서의 경쟁 조건을 생각해보면 쉬울 것이다.

비 충돌 연산은 서로 순서를 바꾸어도 실행 결과에 차이가 없지만 충돌 연산은 순서에 따라 실행 결과가 달라지기 때문에 스케쥴을 결정하기 위해 고려해야 할 대상이 된다. 스케쥴에서 비 충돌 연산의 순서를 바꾸어 직렬 스케쥴이 되면 충돌 직렬 가능 스케쥴(Conflict serializable schedule)이라고 한다. 충돌 직렬 가능 스케쥴에 대한 더 자세한 설명은 다음 링크를 참고.



뷰 직렬 가능성(View serializability)

같은 트랜잭션들로 이루어진 스케쥴 S1과 S2에 대해

S1의 트랜잭션 Ti에서 처음 읽은 데이터 Q값과 S2의 Ti가 Q에 대해 같은 값을 읽고

S1의 Ti가 쓰기 한 값을 다른 트랜잭션 Tj가 읽는다면 S2에서도 마찬가지로 Ti가 수정한 값을 Tj가 읽고

S1의 마지막 쓰기 연산이 S2의 마지막 쓰기 연산과 동일하다면

이 스케쥴을 뷰 동치(View equivalence)라고 한다. 만약 직렬 스케쥴과 뷰 동치인 스케쥴이라면 그 스케쥴을 뷰 직렬 가능 스케쥴(View serializable schedule)이라고 할 수 있다. 뷰 동치 정의가 매우 복잡하다. 좀 더 자세한 내용은 다음 링크를 참고.

충돌 직렬 가능 스케쥴은 뷰 직렬 가능 스케쥴의 부분 집합이고, 뷰 직렬 가능 스케쥴은 보다 많은 스케쥴들을 포함한다. 



직렬 가능한 스케쥴 선택

뷰 직렬 가능 스케쥴 외에도 더 많은 스케쥴들이 직렬 가능할 수 있다. 그러나 사용자가 모든 질의에 대해 미리 다 요청을 보내 놓는 게 아니기 때문에 모든 연산의 순서를 알지 못할뿐더러, 다른 직렬 가능한 스케쥴을 찾는 것은 복잡한 계산을 필요로 한다. 심지어 뷰 직렬 가능 스케쥴조차도 뷰 직렬 가능 스케쥴인지 검증하는 문제는 NP-완전 수준의 문제다. 그렇기 때문에 현실적으로는 충돌 직렬 가능성에 대해서만 고려하여 직렬 가능한 스케쥴을 찾는다고 생각하면 된다.

충돌 직렬 가능성을 판별하기 위해서는 스케쥴의 연산에 따른 선행 그래프(Precedence graph)를 사용하면 된다. 그래프에서 노드는 트랜잭션을, 충돌하는 데이터가 존재하는 노드 간에 엣지를 두면 선행 그래프가 만들어진다. 이 선행 그래프에서 회로(cycle)가 존재하면 충돌 직렬 가능하지 않은 스케쥴이다.

뷰 직렬 가능 스케쥴에 대해 쉬운 검증 방법이 하나 있다. 충돌 직렬 가능 스케쥴에 대해 Blind write(읽지 않고 쓰는 작업)의 존재 여부를 활용하는 것이다. 그러나 이것뿐이다.



다양한 스케쥴

지금까지는 모든 트랜잭션이 실행이 정상적으로 되었을 때에 대해서만 고려한 스케쥴이었다. 그러나 DB에서는 항상 장애에 대한 고려가 필요하다. 회복 가능 스케쥴은 어떤 데이터 Q에 대해 쓰기 작업을 한 트랜잭션 Ti와 Ti가 쓰기 연산한 후에 Q를 읽는 트랜잭션 Tj가 있을 경우 Ti가 먼저 완료된 후에야 Tj가 완료되어야 하는 스케쥴이다. 이 조건이 필요한 이유는 Ti가 실행 도중 철회될 수 있기 때문이다. 이 경우 철회된 값을 읽었던 트랜잭션의 연산을 복구할 수 없는 문제가 발생한다. 이러한 문제가 일어나지 않고, 복구 가능하도록 실행하는 스케쥴을 회복 가능 스케쥴(Recoverable schedule)이라고 한다.

동일한 데이터에 접근했던 트랜잭션이 철회됨에 따라 다른 트랜잭션도 철회되는 현상을 연쇄 철회(Cascading rollbacks)이라고 한다. 힘들게 다 실행해놨더니 다시 복구해야 하는 상황이 되는 것이므로 가능하면 연쇄 철회가 일어나지 않기 위해 노력해야 한다. 연쇄 철회가 필요 없는 스케쥴(Avoids cascading aborts schedule)은 완료된(Committed) 데이터만을 읽도록 허용한다. 즉, 어떤 데이터에 대해 다른 트랜잭션이 먼저 쓰기 연산을 했다면 그 트랜잭션이 먼저 완료되기를 요구한다.

제한적인 스케쥴(Strict schedule)은 어떤 데이터에 대해 쓰기 연산을 한 트랜잭션이 완료되거나 철회되기 전에는 다른 스케쥴이 읽기 연산도, 쓰기 연산도 할 수 없게 하는 스케쥴이다. 즉, 연쇄 철회가 필요 없는 스케쥴보다 더 적은 범주의 스케쥴만을 포함한다. 아래 그림은 각 스케쥴 범주를 보여준다.

출처 : https://en.wikipedia.org/wiki/Schedule_(computer_science)



동시성 제어

지금까지 다양한 스케쥴에 대해 알아보았다. 우리가 원하는 스케쥴은 직렬 가능 스케쥴이면서 회복 가능 또는 연쇄 철회를 방지하는 스케쥴이다. 앞서 언급하였지만 트랜잭션 전체의 연산을 미리 알지 못하기 때문에 이미 트랜잭션을 실행한 뒤에야 어떤 스케쥴인지 판별 가능하다. 그렇기 때문에 어떠한 규약을 통해 실행하면 원하는 스케쥴대로 실행되게 하는 규약을 찾고 싶다. 이러한 규약을 동시성 제어 규약(Concurrency control protocol)이라고 한다. 

지금까지 개발된 동시성 제어 규약으로는 Locking, Timestamp ordering, Multiversion, Optimistic protocol이 있다. 상용 시스템에서는 Locking 규약이 주로 쓰인다. 다음 항목부터 각 동시성 제어 규약에 대해 더 자세히 알아보겠다.



Lock-based Protocol

락(Lock) 모드로는 Shared-lock과 eXclusive-lock 두 가지 모드가 있다. 각 모드를 S 모드, X 모드라고 부르며, S 모드는 읽기 연산을 할 때, X 모드는 쓰기 연산을 할 때 사용한다. 충돌 연산에서도 그랬듯이 S 모드는 서로 호환되지만 X 모드는 호환되지 않는다. 

Lock-based protocol에서 트랜잭션은 모든 읽기/쓰기 연산을 하기 전에 락을 먼저 획득해야 한다. 호환 불가능한 락을 얻으려 할 때는 이전의 락이 풀릴 때까지 기다렸다가 락을 받은 뒤에야 연산을 수행할 수 있다. 마치 세마포어와 비슷하다. X 모드 락은 다른 모드들과 호환되지 않으므로 단 하나의 트랜잭션만이 가질 수 있음을 알 수 있다.



두 단계 락킹 규약(Two-phase Locking Protocol)

기본적인 락 규칙 외에도 직렬 가능 스케쥴을 만들기 위한 추가적인 규칙이 필요하다. 두 단계 락킹 규약은 직렬 가능 스케쥴을 만들기 위한 규약 중 하나이다. 두 단계 락킹 규약에서는 확장 단계(Growing phase)와 수축 단계(Shrinking phase)로 단계를 나누어 확장 단계에서만 락을 추가로 할당받을 수 있고, 락을 풀기 시작하면 수축 단계가 되면 추가적인 락을 얻을 수 없게 한다. 

출처 : http://opensource.telkomspeedy.com/repo/abba/v06/Kuliah/SistemOperasi/BUKU/img/index.html

두 단계 락킹 규약의 변형으로 엄격(Strict) 두 단계 락킹 규약과 엄중(Rigorous) 두 단계 락킹 규약이 있다. 엄격 두 단계 락킹 규약은 트랜잭션이 종료될 때까지 쓰기 락을 계속 가져가고 읽기 락은 중간에 해제 가능하도록 하는 규약이다. 엄격 두 단계 락킹 규약을 준수하면 연쇄 철회가 일어나지 않게 된다. 트랜잭션이 완료된 뒤에야 쓰기 연산이 일어난 데이터에 다른 트랜잭션이 접근하지 못하게 하기 때문이다. 엄중 두 단계 락킹 규약은 읽기 락도 트랜잭션 종료 시까지 유지함으로써 트랜잭션 완료 순서대로 직렬 된다. 두 단계 락킹 규약이 모든 충돌 직렬 가능 스케쥴을 만드는 것은 아니고, 충돌 직렬 가능 스케쥴 중 일부 스케쥴로 실행되게 하는 것뿐이다.

읽기 락을 걸었던 데이터에 대해 쓰기 락을 걸게 될 수도, 쓰기 락을 걸었던 데이터에 대해 읽기 락으로 바꾸려 할 수도 있다. 이러한 변환을 락 변환(Lock conversion)이라고 한다. 락 변환을 고려하여 확장 단계에서는 S 모드 락, X 모드 락을 얻거나 S 모드 락을 X 모드 락으로 변환(upgrade)하는 것만을 허용하며, 수축 단계에서는 S 모드 락, X 모드 락을 풀거나 X 모드 락을 S 모드 락으로 변환(downgrade)하는 것만을 허용한다.

두 단계 락킹 규약이 항상 충돌 직렬 가능한 스케쥴을 생성하는 것에 대한 증명은 선행 그래프를 활용하여 가능하다. 선행 그래프 상에서 선행 노드는 수축 단계에 들어갔음을 의미하며 회로가 존재하기 위해서는 수축 단계에 들어갔던 노드가 다시 확장 단계가 되어야 한다는 것을 뜻하므로 모순이 발생하는 것이 증명 아이디어이다.



락 관리

시스템은 연산에 대해 자동적으로 락을 획득, 해제하도록 할 수 있기 때문에 사용자는 락을 획득하고 해제하는 것에 대해 신경 쓰지 않아도 된다. 시스템은 락을 관리하기 위해 프로세스를 독립적으로 구성할 수도 있다. 락을 관리하는 락 매니저(Lock manager)는 락 테이블(Lock table)을 관리하며, 락 관리는 빠른 시간 안에 수행되어야 하므로 메모리에서 관리한다. 

락을 사용하면서 데드락(Deadlock) 발생 위험이 당연히 존재한다. 데드락에 대한 처리를 하지 않으면 시스템이 멈춰버릴 것이다. 이에 대한 적절한 처리 방법이 필요한데, 나중의 항목에서 몇 가지 방법을 소개할 것이다. 락 충돌이 일어나는 경우 트랜잭션은 락을 획득하기 위해 대기 상태로 기다린다. 그러나 락 배분 정책이 잘못되면 무한정 기다리는 기아 상태(Starvation)가 발생할 수도 있다. 당연히 기아 상태가 발생하지 않도록 잘 설계 및 구현해야 한다.



그래프 기반 규약(Graph-based Protocol)

만약 데이터에 부분 순서(Partial order)가 존재한다면 그래프 기반 규약을 사용할 수도 있다. 다음 링크는 그래프 기반 규약을 설명하는 유튜브 강의이다. 일반적인 데이터라면 부분 순서가 존재하지 않겠지만 DB에서 반드시 운영하는 데이터 중 순서가 반드시 존재하는 데이터가 있다. 바로 색인이다. 그래프 기반 규약 중 가장 간단한 형태인 트리 기반 규약을 알아보자.

출처 : https://sites.google.com/site/projectcodebank/

트리 기반 규약에서는 락을 언제든지 획득하고 해제할 수 있다. 다만 한 번 락을 획득한 항목에 대해서는 다시 락을 획득할 수 없다. 두 단계 락킹 규약과 가장 큰 차이가 락 해제 후에 다시 획득이 가능하다는 것이다. 또한, 한 방향으로만 락이 흘러가기 때문에 데드락이 발생하지 않는다. 그러나 규약상의 이유로 굳이 락을 필요로 하지 않는 데이터에 대해서도 락을 획득해야 한다는 것이 단점이다. 또한, 회복이 불가능한 스케쥴과 연쇄 철회 스케쥴을 생성하기도 한다.



다중 단위 크기 락킹(Multiple Granularity Locking)

락킹 자체에 대해 더 생각해보자. 지금까지는 어떤 데이터 항목에 대해 락을 획득한다고 하였다. 그렇다면 어떤 데이터에 락을 걸 수 있을까? 상용 시스템들은 여러 크기의 락을 지원하며 이를 다중 다중 단위 크기 락킹(MGL)이라고 한다. DB는 계층적으로 DB, 관계(Relation), 페이지(Page), 레코드(Record)로 나뉜다. DB는 여러 관계들의 집합이며, 관계는 여러 페이지의 집합, 그리고 페이지 안에는 여러 개의 레코드들이 존재한다. 상위 계층 노드에 대해 명시적으로 락을 획득할 경우 하위 노드에 대해서 묵시적으로 락을 획득하는 효과가 있다. 

또한, 락 모드를 더 세분화함으로써 동시성 향상을 도모한다. 다중 단위 크기 락킹에 대한 더 자세한 설명은 다음 링크의 유튜브 강의를 참고하기 바란다.



데드락 처리

락 기반 규약에서 데드락은 어쩔 수 없이 발생하며, 처리를 해줘야 한다. 타임 아웃, 방지, 감지 및 해결 세 가지 방식이 있다. 타임 아웃 방식은 일정 시간 이상 락을 위해 대기하지 않게 하고, 방지 방식은 두 단계 락킹 규약이 아닌 그래프 기반 락킹 규약을 사용하게 하는 방식이다. 방지 방식에는 트랜잭션에서 요구하게 될 모든 락을 미리 획득하는 방법도 포함되는데, 실제로는 트랜잭션에서 접근하게 될 데이터를 미리 알 수 없으므로 실효성이 없다.

또 다른 대표적인 데드락 방지 방식은 Wait-die 방식과 Wound-wait 방식이 있다. Wait-die 방식은 락 충돌 발생 시 늙은 트랜잭션(시작한 지 오래된)일 경우 기다리고, 어린 트랜잭션(시작한 지 덜 오래된)일 경우 스스로를 철회하는 방식이다. 그리고, Wound-wait 방식은 늙은 트랜잭션이 젊은 트랜잭션이 가지고 있던 락을 필요로 할 경우 젊은 트랜잭션을 철회시켜버린 뒤 락을 가져가고, 젊은 트랜잭션이 늙은 트랜잭션의 락을 필요로 할 경우 기다리는 방식이다. 두 방식 모두 오래된 트랜잭션이 유리한 방식이므로, 기아 상태가 발생하지 않기 위해서는 철회되기 전 처음 시작할 때의 타임스탬프를 기반으로 얼마나 오래된 트랜잭션인지 판단해야 한다. 오래된 트랜잭션을 우선하는 이유는 이미 많은 작업을 수행하였기 때문에 철회하는 비용이 더 클 가능성이 높기 때문이다.

데드락 감지 방식으로는 방향성을 가지는 대기 그래프를 이용한 방법이 있다. 그래프에 회로가 존재하면 데드락이 발생한 것인데, 간단한 알고리즘으로 회로가 존재하는지 알 수 있다. 감지는 쉽지만 해결은 쉽지 않다. 상용 시스템에서는 데드락을 일으킨 트랜잭션인 Current blocker를 철회시키는 방법을 많이 쓴다. Current blocker는 대기 그래프에서 회로를 만든 노드이다. 철회 비용이 저렴한 트랜잭션을 철회하면 더 좋겠지만, 그 계산이 어렵기 때문이다. 

락 대기 조차도 간혹 일어나는데, 데드락은 더더욱 간혹 일어난다. 상용 시스템에서는 다양한 요구사항들이 존재하고, 데드락은 시스템에 있어서 치명적인 부분이 아니기 때문에 앞서 언급한 방법들이 빈틈이 많아 보여도 큰 문제가 되지 않는다.



팬텀 현상(Phantom Problem)

관계에 새로운 행을 추가하거나 삭제하는 경우 팬텀 현상이 일어난다. 행 삽입 및 삭제 시 행 수준의 락을 지원하는 시스템에서 팬텀 현상이 일어날 수 있다. 어떤 관계에 대한 집계 연산을 수행하는 트랜잭션은 읽기 락을 사용할 텐데, 새로운 행을 추가하려는 트랜잭션은 쓰기 락을 사용하면서 서로 호환되지 않기 때문에 집계 연산을 하는 트랜잭션이 새로 추가된 행을 인식하지 못하게 된다. 이는 행을 삭제할 때도 비슷하다.

행을 추가하거나 삭제할 때마다 테이블 수준의 락을 사용하는 것은 성능상에 치명적이므로 피해야 한다. 팬텀 현상을 피하기 위해서는 색인에 대해 락을 거는 방식이 널리 쓰인다. 색인에 대해 크래빙(Crabbing) 방식으로 락을 사용한다. 크래빙 방식은 트리 기반 규약으로 락을 사용하는 것인데, 부모 노드에 대해 먼저 락을 건 뒤, 자식 노드에 대해 락을 잡고 부모 노드에 걸었던 락을 해제하는 방식이다. 그러다 행을 추가 혹은 삭제함에 따라 부모 노드에 변화가 생기면 부모 노드에 대해 다시 락을 획득하려고 한다. 당연히 데드락이 발생할 수 있는 방법이므로 데드락 처리가 필요하다. 크래빙 방식 외에는 Predicate locking, Next-key locking 등의 방식이 제안되어 있다.



트랜잭션 고립성 제어

어떤 응용 프로그램은 트랜잭션이 완벽한 일치성을 요구하지 않을 수도 있다. 일치성의 수준이 조금 떨어지더라도 시스템의 성능이 더 향상되는 것을 원할 수 있기 때문이다. 일치성 수준을 완화(Weak levels of Consistency) 하기 위해 보편적으로 지원되는 형식이 두 단계 일치성이다. 읽기 락을 제한 없이 획득 및 해제할 수 있기 때문에 직렬 가능 스케쥴임이 보장되지 않지만 성능을 향상할 수 있다.

두 단계 일치성 중에서 커서 안정 방식(Cursor stability)은 데이터 읽기를 위한 커서가 위치하는 동안에만 읽기 락을 유지하는 방식이다. 커서가 위치해있는 동안은 읽기 락이 유지되므로 커서가 위치해있는 동안은 다른 트랜잭션에 의해 데이터가 수정되지 않고, 안정된 데이터를 보인다.

심지어 트랜잭션을 선언할 때 성능을 위해 고립성 수준을 선택할 수도 있다. Serializable, Repeatable read, Read committed, Read uncommitted 네 방식 중 Serializable을 제외하고는 직렬 가능한 스케쥴로 실행하지 않는다. 상용 시스템에서는 기본적으로 Read committed 방식을 기본 수준으로 선택한다. 각 수준별 차이는 다음과 같다. (Snapshot이라는 소개하지 않는 방식도 포함되어 있다.)

출처 : http://thesqlgirl.com/2016/11/01/sql-transaction-isolation-levels/

Read uncommitted 방식은 읽기 연산에서 록을 잡지 않지 때문에 읽은 데이터에 신빙성이 전혀 없다. 그러나 집계 연산 같은 작업을 위해서는 유용하게 쓰일 수 있다. Read committed인 경우에는 록을 잡고 읽은 후에 즉시 록을 풀기 때문에 다른 값을 수정하는 도중 문제가 생길 수 있다. Repeatable read는 한번 읽은 값은 트랜잭션 끝까지 유지가 되므로 처음 트랜잭션이 시작할 때의 값은 그대로 읽을 수 있으나 트랜잭션 실행 도중 생긴 변화는 읽을 수 없다.



장애(Failure)와 복구(Recovery)

장애는 트랜잭션 장애, 시스템 장애, 디스크 장애 세 가지로 나뉜다. 트랜잭션 장애는 데드락이나 사용자의 요구, 시스템의 판단 등에 의해 트랜잭션을 철회시키면서 발생할 수 있는 장애이다. 시스템 장애에는 정전으로 시스템이 변경 사항을 저장 장치에 쓰기 전 컴퓨터가 아예 꺼져버리거나 운영 체제 문제로 인해 DB 시스템이 종료되는 장애 등이 포함된다. 디스크 장애는 하드웨어 결함 등으로 인해 디스크 내용이 소실되는 장애이다. 장애는 예고 없이 다양한 형태로 발생할 수 있다. 장애가 발생하면 시스템이 복구 작업을 해야 하며, 복구 알고리즘은 정상 상태에 복구를 위해 미리 수행하는 작업과 장애 발생 후 복구를 위해 수행되는 작업으로 나뉜다. 

정상 상태에 복구를 위해 미리 수행하는 작업은 기본적으로 로그 쓰기이다. 로그는 안전 저장 장치(Stable storage)라는 곳에 쓰는데, 안전 저장 장치는 어떠한 장애가 발생하여도 저장 내용이 상실되지 않는다는 가정을 하는 가상적인 저장 장치이다. 이러한 가정 없이는 저장 내용에 대한 최소한의 안정성을 기대할 수 없기 때문에 복구에 대해 논하기 어렵다. 실제로 안정 저장 장치는 RAID(Redundant Array of Independent Disks)나 중복 분산 저장 등으로 구현된다.



데이터의 위치

DB 시스템에서는 동일 데이터가 디스크 블록, 메인 메모리 상의 데이터 버퍼 블록, 트랜잭션 프로세스의 메모리 세 곳에 위치할 수 있다. 디스크 블록에 있는 데이터는 시스템이 종료되거나 메모리가 고장이 나더라도 손상되지 않는다.

DB의 데이터는 기본적으로 디스크 블록에 존재하며 DBMS에 의해 필요한 경우에만 시스템 버퍼에 로드한다. 트랜잭션은 시스템 버퍼에 있는 값을 직접 참조하지 않고 트랜잭션의 메모리 공간에 복사한 값을 사용한다. 트랜잭션이 데이터에 쓰기 연산 시 시스템 버퍼에 반영되고 DBMS는 시스템 버퍼 관리 원칙에 의해 시스템 버퍼의 데이터를 디스크 블록에 쓰기 작업을 수행한다.



로그(Log)

상용 DB 시스템은 복구를 위해 로그 방식을 사용한다. 로그 방식의 핵심은 DB에 변화가 생기기 전 변화에 대한 기록을 안전 저장 장치에 저장하는 원칙을 지키는 것이다. 이러한 원칙을 WAL(Write Ahead Logging) 원칙이라고 한다. 구체적으로 로그를 어떻게 남기는지에 대한 방법은 여러 가지가 존재할 것이다. 디스크와 메모리 간의 데이터 이동은 디스크 블록 단위로 이뤄지고, 로그를 저장하는 로그 블록도 디스크 블록 단위로 저장된다. 또한, 여러 트랜잭션이 함께 로그를 남기기 때문에 로그 블록에는 여러 트랜잭션의 로그가 함께 저장되어 있다.

항상 처음부터 모든 로그를 확인하여 복구하는 것은 시간이 너무 오래 걸리므로 검사점(Checkpoint)을 두어 해당 시점까지는 장애가 없었다는 증표를 남겨둔다. 검사점을 만들 때는 아직 디스크에 반영되지 않은 모든 로그들과 변경된 데이터들을 디스크에 반영시킨다. 메모리와 디스크의 내용을 동일하게 만드는 것이다. 검사점을 만들고 나면 장애가 발생했을 때 검사점 이후의 로그들만 이용하여 복구 작업을 수행하면 된다.

장애가 발생한 뒤 복구를 위한 작업에 대해 로그와 관련된 내용만 설명하면 다음과 같다. 장애 발생 후에는 로그와 디스크에 저장된 데이터만이 존재한다. 먼저 검사점 이후의 로그들을 분석하여 완료되었지만(committed) 디스크에 반영되지 않아 재연산을 해야 할 트랜잭션들을 redo 리스트에 추가하고 시작하였지만 완료되지 않았던 트랜잭션을 undo 리스트에 추가한다. 그리고 마지막 로그부터 검사점까지 역방향으로 undo 리스트에 존재하는 작업을 undo 하고, 다시 검사점부터 정방향으로 redo 리스트에 있는 작업들을 redo 한다.

로그 블록을 디스크에 쓰는 것은 시간이 많이 소요되기 때문에 버퍼링 후 디스크에 반영한다. 트랜잭션을 완료 처리하기 전에 안전 저장 장치에 로그 쓰기 작업이 무조건 선행되어야 하기 때문에 버퍼링 되는 트랜잭션 여러 개가 동시에 완료되기도 한다.



데이터 페이지 버퍼링(Data Page Buffering)

데이터 블록이 디스크에 쓰이는 동안은 해당 데이터 블록 내의 데이터가 수정되지 않도록 해야 한다. 이전의 락과 달리 여기서는 래치(Latch) 또는 세마포어를 사용한다. 디스크 쓰기 속도는 매우 느리기 때문에 효율을 위해 데이터 페이지도 버퍼링 한다. 현대 운영 체제는 대부분 가상 메모리 기법을 사용하는데, 이 때문에 듀얼 페이징(Dual-paging) 문제가 발생할 수 있다. 듀얼 페이징은 메모리의 내용이 스왑핑(Swapping)에 의해 이미 디스크에 쓰인 상태에서 그 데이터를 디스크에 쓰려고 할 경우 데이터 페이지가 메모리에 다시 로드된 뒤, 디스크에 다시 쓰이는 문제이다. 이러한 문제가 생기지 않기 위해서는 운영 체제가 DB 시스템의 데이터 블록과 관련된 메모리를 스왑 하려는 경우 DB 영역에 쓰기 하도록 협의되도록 해야 한다.

데이터 페이지 버퍼링에는 Steal 정책과 Force 정책이 있다. Steal 정책은 트랜잭션이 완료되기 전 디스크 블록에 변경 사항이 쓰일 수 있는지에 대한 정책이고, Force 정책은 트랜잭션이 완료되었을 때 항상 디스크 블록에 변경 사항을 적용해야 하는지에 대한 정책이다. Force 정책은 매번 트랜잭션이 완료될 때마다 디스크 접근을 요구하므로 성능에 큰 영향을 끼칠 수 있다. Steal/Force 정책을 사용하는 시스템이라면 복구 작업 시 undo 작업만 하면 되고, NotSteal/NotForce 정책을 사용하는 시스템이라면 redo 작업만 하면 되기 때문에 어떤 정책을 사용하는 시스템인지에 따라 복구 작업에 차이가 있다고 봐야 할 것이다.



원격 백업(Remote Backup)

누구도 예상하지 못했던 천재지변에 의해 시스템 컴퓨터가 손상을 입을 수도 있다. 항상 예로 드는 것이 9.11 테러다. 9.11 테러로 공격받은 빌딩에는 많은 시스템 장비들이 있었고, 테러로 인해 손상되었지만 원격 백업이 있었기 때문에 복구될 수 있었다. 원격 백업 시스템은 주 시스템(Primary system)이 있는 곳 외에도 다른 지역에 백업 시스템(Backup system)을 둬서 시스템 가용성을 높인다. 주 시스템에 문제가 생겨 백업 시스템이 주 시스템 역할을 수행해야 할 경우 백업 시스템은 로그들을 이용하여 복구 연산을 하여 주 시스템과 같은 상태로 만든 뒤 원래 시스템의 역할을 수행하면 된다.

백업 시스템이 빠르게 주 시스템 역할을 수행할 수 있도록 하려면 백업 시스템이 주 시스템으로부터 받은 로그를 즉시 적용해둬야 하며, 이를 Hot spare라고 한다. 백업 시스템 환경에서 트랜잭션의 지속성을 보장하기 위해서는 트랜잭션의 완료 로그가 필요한데, 백업 시스템에 그 로그가 기록되는 데는 시간이 걸린다. 백업 시스템에 로그를 적용시키는 방식으로 One-safe, Two-very-safe, Two-safe 방식이 있다. One-safe 기법은 트랜잭션이 완료되었을 때 주 시스템에만 적용이 완료되면 되는 방식이다. 가장 빠르다. Two-very-safe 기법은 트랜잭션이 완료되었을 때 백업 시스템들 모두에 적용이 완료되어야 하는 방식으로, 가장 느리다. Two-safe 방식은 하나의 백업 시스템에만 적용되면 되는 방식으로 Two-very-safe보다 느슨하지만 더 빠를 것이다.

원격 백업 시스템의 대안으로는 분산 데이터베이스 방식이 있다.



저장 장치(Storage)

DB 시스템은 저장 장치와 매우 밀접한 관계를 가진다. 저장 장치의 종류도 여러 가지고, 저장 장치를 시스템과 연결하는 방법 또한 여러 가지다. 흔히 많이 쓰이는 SATA 인터페이스로 연결되는 것뿐만 아니라 네트워크 상으로 연결되는 SAN(Storage Area Network)NAS(Network Attached Storage) 등이 있다. 기존에는 HDD를 많이 썼었기 때문에 디스크 성능 평가 또한 중요한 이슈 중 하나였으나 서서히 SSD를 사용하는 시스템들이 많이 늘어나고 있고, 이전의 운영 체제 편에서 디스크 스케쥴링에 대한 내용을 정리하였기 때문에 이 글에서는 설명하지 않겠다.

DB 시스템에서 저장 장치에 대해 생각할 때는 거의 무조건 RAID 시스템을 생각하면 된다. 보통 RAID 0 혹은 5가 많이 쓰인다. RAID는 낭비처럼 보일지 모르지만 시스템 장애가 생겨 데이터를 잃었을 때 문제 해결을 위한 비용을 생각해보면 절대 비싼 것이 아니다.



파일/레코드 구성(File/Record Organization)

DB는 여러 파일들로 구성되고, 파일은 여러 레코드로 구성되며, 레코드는 여러 필드로 구성된다. 레코드를 저장하는 가장 간단한 방법은 고정 길이 레코드(Fixed length record)이다. 모든 레코드의 길이가 동일하다고 가정하지만, 실제로 이런 경우는 잘 없다. 보통은 가변 길이 레코드(Variable length record)로 저장되며, 이 방식은 레코드 앞에 레코드의 속성별 offset과 길이를 나타내는 정보가 존재한다.

레코드들은 페이지에 위치하게 되는데, 그 구성 방법으로는 슬롯 페이지 구조(Slotted Page Structure)가 널리 쓰인다. 페이지의 헤더에는 페이지의 ID, 소유자, 남은 공간 등 여러 정보와 슬롯(slot) 테이블이 있다. 각 슬롯은 실제 레코드의 주소를 가진다. 페이지 내에서 레코드가 이동할 때는 슬롯 번호가 바뀌지는 않고 슬롯에 있는 페이지 내 주소만 변경하면 된다. 외부에서는 페이지 번호와 슬롯 번호만 알면 해당 레코드가 접근할 수 있다.

출처 : 큐브리드

파일 내 레코드 구성 방법은 힙 파일, 순차 파일, 해시 파일로 나뉜다. 힙 파일은 레코드 간에 순서 없이 여유 공간 아무 곳에 레코드가 위치한다. 순차 파일은 레코드가 파일 내에 순차적으로 존재하도록 구성하여 각 레코드를 순차적으로 접근할 때 효율적이다.

일반적으로 단일 관계에 속하는 레코드는 한 물리적 파일에 저장된다. 그러나 DB는 조인 등의 연산으로 여러 관계의 데이터를 함께 사용하는 경우가 잦기 때문에 다중 테이블 집약(Multitable clustering) 방식으로 저장하기도 한다. 



색인(Index)

DB 튜닝의 꽃이라 볼 수 있는 색인에 대해 알아본다. 색인의 개념과 주요 색인 자료 구조인 B+ 트리, 확장 해싱, 비트맵에 대해 살펴볼 것이다. 색인의 목적은 데이터를 빨리 찾는 것이다. 정렬 색인(Ordered index)은 키를 기준으로 정렬되어 있으며, 해쉬 색인(Hash index)은 정렬되어 있지 않다.

어떤 색인을 사용하는 게 좋을지에 대해 평가하기 위해 사용될 질의(Query) 종류를 먼저 고려해야 한다. 질의는 특정 값의 레코드를 찾는 Exact match 질의와 일정 범위에 속하는 레코드를 찾는 Range 질의로 나뉜다. 당연히 정렬 색인은 키에 대해 정렬되어 있기 때문에 Range 질의에 적합하고, 해쉬 색인은 정렬되어 있지 않기 때문에 Range 질의에 적합하지 않다.

주 색인(Primary index)은 실제 파일의 레코드 순서와 동일한 순서로 존재하는 색인이고, 이차 색인(Secondary index)은 실제 파일 내 레코드 순서와 관련 없이 키에 대해 정렬된 상태로 존재하는 색인이다. 주 색인은 단 하나만 존재할 수 있고, 이차 색인은 여러 개를 만들어 둘 수 있다. 밀집 색인(Dense index)은 모든 레코드에 대한 색인 레코드가 존재하고 희소 색인(Sparse index)은 일부 레코드에 대해서만 색인 레코드가 존재한다. 희소 색인은 키를 기준으로 데이터 레코드들이 정렬되어 있지 않으면 의미가 없다. 성능 등의 목적으로 다단계 색인(Multilevel index)을 구성해서 쓰는데, 색인은 항상 정렬되어 있으므로 상위 수준에 희소 색인을 적용할 수 있다. 이차 색인은 데이터 레코드 순서와 관련 없이 존재하는 색인이므로 반드시 밀집 색인이어야 한다.

데이터 레코드가 삽입되거나 삭제되는 경우에도 색인 파일에 대한 갱신이 이뤄져야 한다. 그렇기 때문에 색인을 만들어두는 것은 오버헤드가 발생한다고 하는 것이다. 그러나, 색인 유무에 따라 검색 속도의 차이가 매우 크기 때문에 많은 경우에 색인은 꼭 필요하다.



B+ 트리

B+ 트리는 균형 트리(Balanced tree) 상태를 유지하는 자료 구조이다. 많은 상용 시스템에서 색인을 위한 자료 구조로 쓰인다. 데이터에 변화가 일어나도 지역적인 변화만 있으면 되기 때문에 색인 유지에 용이하다. 트리 하면 이진 트리를 주로 생각하지만 B+ 트리는 자식을 매우 많이(예를 들면 1000개) 가지도록 만든다. 즉, 좌우로 매우 뚱뚱한 트리가 된다. B+ 트리의 노드 크기는 보통 4KB, 8KB, 16KB 정도인데, 만약 블록의 크기가 4KB이고 탐색 키의 크기가 40B라고 하면 색인 노드의 fanout(포인터 개수)는 100개가 되며, 100만 개의 노드를 가지는 트리의 깊이(Depth)는 4로 매우 작다. 깊이를 작게 하는 것이 중요한 이유는 디스크 접근을 줄이기 위해서이다. 색인 또한 디스크에 저장되기 때문이다.

B+ 트리의 단말 노드는 실제 레코드를 가리키는 포인터를 가진다. 또한, 다음 순서의 색인 노드를 가리키는 포인터도 가진다. 이렇게 함으로써 Range 질의에 효과적인 모습을 보인다. 조건에 맞지 않을 때까지 포인터를 따라 다음 순서의 레코드를 차례로 탐색하면 되기 때문이다.

B+ 트리가 균형 상태를 유지하도록 하는 삽입, 삭제 연산의 자세한 설명은 다음 링크를 참고.



확장 해싱(Extendible Hashing)

정적 해싱(Stati hashing) 방법의 문제는 균일하고(Uniform) 임의적인(Random) 해시 함수를 찾기 어렵기 때문에 점점 데이터가 많아짐에 따라 충돌이 많이 일어나게 되고, 충돌로 인한 성능 저하가 발생한다는 것이다. 확장 해싱은 해시 함수가 동적으로 변하게 함으로써 이 문제를 해결한다. 해시 방법은 Exact match 질의에만 적합하다. 해시 결과 기존 데이터 레코드의 순서와 전혀 다른 순서가 되기 때문이다.

확장 해싱에서는 해싱 결과 값의 앞쪽 bit를 이용하여 버킷 주소를 결정한다. 버킷은 두 개로 분리되거나 두 개가 합쳐져 하나의 버킷이 될 수 있다. 확장 해싱의 작동 원리에 대한 자세한 설명은 다음 링크를 참고.



비트맵(Bitmap)

비트맵 색인은 탐색 키의 종류가 적을 때 다중 속성에 대한 질의에 유용한 색인이다. 예를 들어, 글의 상태와 같이 공개, 비공개 정도로 적은 종류의 키가 되거나, 급여를 (달러 기준) 10000 단위로 범주를 나눠놓은 경우가 해당된다.

비트맵 색인은 다중 속성에 대한 조건을 bit 연산하여 조건을 결과 값을 만든다. 미리 준비되어 있는 비트맵 색인들도 마찬가지로 bit 연산하여 결과 값을 만들면 조건을 만족하는 레코드의 위치들에 대한 bit 벡터가 만들어진다. 비트 연산은 매우 빠르게 수행 가능하고, 용량 또한 작기 때문에 앞서 언급한 바와 같이 탐색 키의 종류가 적다면 매우 유용한 색인이다.

비트맵 색인에 대한 좀 더 자세한 설명은 다음 링크를 참고.



질의 처리(Query Processing)

DBMS에서 질의 처리 과정에 대해 알아본다. 질의가 입력되면 파싱 및 번역 단계에서 구문 검사, 타입 검사, 권한 검사 등을 한 뒤, 시스템에서 처리하기 쉬운 형태로 변환한다. 시스템마다 차이는 있지만 주어진 SQL과 동일한 결과를 주는 관계 대수 형태가 된다. 표현식은 질의 최적화 도구를 거쳐 더 빠르게 실행 가능한 형태로 보완된다. 이때, 질의 최적화 도구는 최적화를 위해 통계 정보가 담긴 카탈로그(Catalog)를 참고한다. 최적화를 마친 뒤 만들어진 실행 계획이 실행 엔진에서 실행된 후 결과를 사용자에게 반환한다. 아래 그림은 그 과정을 다이어그램으로 나타낸 것이다.

출처 : http://sungsoo.github.io/2014/05/27/query-processing.html

질의 최적화는 매우 중요하다. 컴파일러도 최적화 옵션을 사용한 것과 아닌 것의 속도 차이가 많이 나는 것처럼 DB 시스템도 최적화를 적용한 질의와 최적화를 적용하지 않은 질의의 실행 속도에 큰 차이를 보인다. 비싼 연산인 조인 연산은 구현 알고리즘도 여러 가지이고, 색인을 어떻게 사용하는지도 성능에 큰 영향을 끼치는 요소이다. 심지어 어떤 경우는 색인을 아예 사용하지 않는 것이 더 빠른 경우도 있다. (교수님께서 수업 시간에 말씀하시길 조건 대상이 데이터의 10%를 넘어가면 보통 색인을 사용하지 않는 것이 더 빠른 경향을 보였었다고 하셨다.) 질의 최적화를 위해서는 당연히 예측을 잘 하는 것이 중요하고, 예측을 잘 하기 위해 통계 정보가 있는 카탈로그를 참고한다.

질의 비용(Query cost)에 영향을 끼치는 요소는 물론 다양하지만, 그중에서도 가장 큰 요소는 디스크 접근이며, 보통 다른 비용을 압도(Dominant)한다. 앞으로 질의 처리 비용을 계산할 때는 디스크 접근 횟수와 블록 전송 시간을 위주로 단순화하여 계산할 것이다. 물론 질의 결과를 디스크에 쓰는 일도 일어나지만, 이는 버퍼 관리 정책 등에 의해 계산하기 어려운 부분이므로 배제한다.



선택 연산(Select Operations)

가장 기초적인 선택 연산은 관계의 처음부터 끝까지 모두 선형 탐색하는 방법이다. 데이터 레코드가 디스크 내에 순차적으로 위치하고 있다는 가정 하에 첫 데이터 레코드가 존재하는 디스크 블록을 찾는 시간과 전체 데이터 블록을 전송하는 시간이 든다.

주 색인을 이용하여 어떤 키 값을 가지는 레코드를 찾는 연산(Exact match 질의)은 B+ 트리를 탐색하는 시간이 든다. B+ 트리의 단말 노드에서 데이터 노드까지 탐색하여야 하기 때문에 (트리의 깊이 + 1)만큼의 디스크 탐색 및 데이터 전송 시간이 요구된다. 만약 조건을 만족하는 레코드가 여러 개라면 조건을 만족하는 레코드가 들어있는 블록 개수만큼의 전송 시간이 더 요구된다. 만약 이차 색인이라면 데이터 레코드의 위치가 순서대로 있지 않기 때문에 매 레코드마다 디스크 탐색 시간이 더 요구될 것이다.

비교 연산이라면 거의 대부분 조건을 만족하는 레코드가 여러 개다. 주 색인에 대한 경우 조건을 만족하는 레코드를 찾은 뒤 선형 탐색을 하면 되지만, 이차 색인은 선형 탐색이 되지 않기 때문에 더 많은 시간이 요구된다. 이차 색인의 경우 디스크 탐색 시간이 매우 많이 들 수 있기 때문에 색인을 사용하지 않는 것이 더 빠를 수도 있다.

여러 조건에 대한 논리곱 선택 연산인 경우 첫 조건이 중요하다. 첫 조건 결과 레코드가 가장 적어지도록 해야 이후 조건들을 빠르게 적용 가능하다. 만약 조건 결과를 만족하는 레코드의 양이 메인 메모리 크기를 초과한다면 조건 결과를 디스크에 다시 써야 하기 때문에 시간 차이가 많이 발생할 것이다. 조건에 대한 다중 키 색인이 있다면 더 좋겠지만, 그렇지 않은 경우가 많다. 단일 속성 색인을 이용하는 경우 색인별로 조건을 만족하는 레코드 리스트를 만들고, 리스트들에 대한 교집합 연산을 거쳐 최종 레코드 리스트를 만든 뒤 레코드를 읽어올 수 있다. 논리합 선택 연산일 경우 이전처럼 각 조건을 만족하는 리스트를 구한 뒤 합집합 연산을 거칠 수도 있지만, 선형 탐색이 나은 경우가 많다. 부정 조건은 매우 적은 수의 레코드가 조건을 만족하는 경우가 아니라면 선형 탐색이 더 낫다.



정렬(Sorting)

메인 메모리에 데이터가 모두 로드될 수 있다면 당연히 널리 알려진 퀵 정렬 혹은 힙 정렬 등을 사용할 것이다. 하지만 DB에서는 메인 메모리보다 많은 데이터를 다뤄야 하는 경우를 상정해야 하며, 이 때는 외부 병합 정렬(External merge sort) 방법이 적합하다. 외부 병합 정렬은 부분적으로 정렬하고 병합하기를 반복하는 방법으로, 기존의 병합 정렬 아이디어를 생각하면 쉽다. 외부 병합 정렬에서 부분적으로 병합된 데이터의 양도 메인 메모리 크기보다 클 수 있다. 그러나 각 부분을 병합하기 위해서는 순차 탐색을 하면 되기 때문에 메모리 크기보다 많은 데이터를 정렬할 수 있다. 다음은 위키백과에서의 설명이다.

예를 들어, 900MB의 데이터를 100MB의 RAM을 사용하여 정렬을 해야 한다고 해보자.
1. 100MB 데이터를 주메모리에 읽어 들이고, quicksort와 같이 일반적인 알고리즘을 사용하여 정렬한다.
2. 정렬된 데이터를 디스크에 쓴다.
3. 1,2번 과정을 9번 반복한다. 그러면 100MB짜리 파일이 9개 생긴다.
4. 9개의 파일에서 각각 처음부터 10MB 씩을 메모리(입력 버퍼)에 로딩한다. 10MB의 출력을 위한 버퍼도 만들어둔다.
5. 9 way merge를 수행하고 결과를 출력 버퍼에 쓴다. 출력 버퍼가 차면 파일에 쓰고, 출력 버퍼를 비운다. 9개의 입력 버퍼가 비워지면, 다음 10MB를 읽는다.

초기 분할을 위해 모든 블록에 대해 읽고 쓰기를 해야 하므로 총 데이터 양의 2배만큼의 블록 전송 비용이 들고, 병합을 위한 pass가 log를 취한 만큼 발생한다. (병합 정렬의 원래 시간 복잡도를 생각하자.) 각 부분을 읽고 쓸 때마다 디스크 탐색 시간도 발생한다. 좀 더 정리된 계산은 다음 링크를 참고.



조인 연산(Join Operations)

조인 연산을 수행하는 방법은 중첩 루프 조인(Nested loop join), 블록 중첩 루프 조인(Block nested loop join), 색인 중첩 루프 조인(Indexed nested loop join), 병합 조인(Merge join), 해시 조인(Hash join)으로 나뉜다. 각 방법을 살펴보자.

중첩 루프 조인은 입력된 두 관계에 카티전 곱한 연산에 주어진 조건을 만족하는 레코드를 찾는 방법이다. 일반적인 이중 루프와 형태가 같다. 최악의 경우 한 관계 내에 존재하는 모든 레코드에 대해 조인 대상 관계 전체를 읽어야 한다. 가장 좋은 경우는 두 관계 모두 메모리에 로드할 수 있는 경우이다. 이 경우 메모리에서 바로 계산 가능하므로 두 관계의 데이터 블록을 전송하는 시간만이 발생한다.

중첩 루프 조인은 레코드 단위로 생각한 것이고, 실제로는 디스크 접근 연산이 적은 블록 중첩 루프 조인 방법이 사용된다. 관계의 데이터에 해당하는 블록만큼의 디스크 접근으로 접근 횟수가 많이 줄어든다. 가장 나쁜 경우는 루프상으로 외부에 해당하는 관계의 블록마다 내부에 해당하는 관계의 모든 블록을 읽어야 하는 경우이다. 외부 테이블의 블록 개수만큼 내부 테이블을 탐색해야 하므로 시간이 많이 든다. 최대한 외부 테이블의 블록을 많이 메모리에 올려둬야 내부 테이블의 탐색 시간을 줄일 수 있다. 

색인 중첩 루프 조인은 내부 테이블에 색인이 존재하는 경우 가능하다. 내부 테이블의 블록마다 색인 블록을 디스크에서 찾아야 하는 경우 최악의 상황이 된다. 차라리 이 경우엔 색인을 사용하지 않는 것이 더 빠르다. 

병합 조인은 동등 조인과 자연 조인 연산을 하려 할 때 조인 연산을 수행할 테이블이 정렬되어 있는 경우에 사용 가능하다. 만약 정렬되어 있지 않다면 외부 병합 정렬 알고리즘을 수행해야 한다. 병합 조인을 할 때는 두 테이블을 차례로 한 번 읽기만 하면 되기 때문에 효율적이다. 만약 정렬 시간이 많이 들지 않는다면 좋은 성능을 기대할 수 있다.

해시 조인 또한 동등 조인과 자연 조인에만 적용 가능한 방법이다. 조인 대상 테이블에 해시 함수를 적용하여 작은 부분으로 나눈 뒤, 같은 부분 내의 데이터끼리만 조인 연산을 수행하는 것이 핵심 아이디어이다. 각 분할을 메인 메모리에 로드한 뒤 조인 연산을 수행할 수 있기 때문에 좋은 성능을 기대할 수 있다. 만약, 각 분할이 메인 메모리에 모두 로드될 수 없다면 해시 조인을 쓰지 않는 것이 나을 수 있다.

기본적으로 조인 연산에 있어 가장 좋은 상황은 하나의 테이블이라도 메인 메모리에 모두 로드될 수 있는 상황이다. 이 경우 상대 테이블을 한 번 순차 탐색을 수행하기만 하면 조인 연산을 마칠 수 있기 때문에 가장 빠르다. 조인 연산에 대한 좀 더 종합적이고 자세한 설명은 다음 링크를 참고.



수식 평가(Evaluation of Expressions)

지금까지 살펴본 것은 관계 대수의 각 연산이었다. 질의 수행은 관계 대수의 결과에 또다시 관계 대수를 연산한다. 연결된 연산은 구체화(Materialization), 파이프라이닝(Pipelining) 방식으로 연산될 수 있다. 구체화 방식은 각 단계의 관계 대수 중간 결과를 임시 저장하여 다음 연산에게 제공하는 방법이다. 중간 결과를 매번 디스크에 쓰고 다음 관계 대수로 읽어 들여야 하기 때문에 비용이 많이 든다. 파이프라이닝 방식은 중간 결과를 디스크에 쓰지 않고 바로 다음 관계 대수에게 전달하는 방법으로 디스크 접근이 발생하지 않아 효과적이지만 모든 경우에 가능한 방식은 아니다. 정렬 연산만 생각해봐도 중간 결과를 다음 관계 대수에게 전달할 수 없다.

파이프라이닝은 요구 주도 방식과 생산 주도 방식으로 나뉜다. 요구 주도 방식은 상위 수준 연산자가 하위 수준 연산자에게 계산 결과를 요구하는 방식으로 Pull 모델이다. 호출 간에 상태를 유지해야 하며, 상태에 따라 다음 입력 요청을 처리한다. 반면, 생산 주도 방식은 하위 수준 연산자가 상위 수준 연산자에게 계산 결과를 주는 방식으로 Push 모델이다. 생산 주도 방식은 연산자 간의 출력 버퍼를 유지해야 한다.



데이터베이스 개발 관점 정리

지금까지 DB 시스템을 개발하기 위해 생각해봐야 할 점들을 살펴보았다. 시스템에서 논리적 작업 단위인 트랜잭션의 성질과 여러 트랜잭션을 동시에 실행하기 위한 동시성 제어, 장애 대응을 위한 복구, 데이터가 저장될 저장 장치, 질의 처리 속도 향상을 위한 색인, 질의 처리의 자세한 방법에 대해 알아보았다.

트랜잭션은 ACID 성질을 충족해야 한다. ACID 성질을 충족하면서도 자원을 효율적으로 사용하기 위해 여러 트랜잭션을 동시에 실행하고 싶다. 문제없이 동시 실행을 하기 위한 규약으로 두 단계 락킹 규약, 트리 기반 락킹 규약을 알아보았다.

장애가 발생했을 때 복구를 하기 위해서는 평소에 수행하는 작업들에 대한 로그가 필요하다. 장애가 발생한 뒤에는 로그를 기반으로 undo, redo를 수행하면 된다. 또한, 시스템이 망가질 것을 대비하여 원격 백업 시스템을 둠으로써 시스템 가용성을 향상할 수 있다.

저장 장치 특성과 DB에서 데이터 관리를 위해 파일을 어떻게 구성하는지도 알아보았다. 데이터 레코드들은 페이지에 소속되며, 페이지 내에는 헤더와 각 레코드를 가리키는 슬롯들로 구성되어 있다.

색인은 질의 처리 속도 향상의 핵심이다. 어떤 질의를 처리할 지에 따라 다른 색인을 사용해야 효과적이다. 질의 종류는 크게 Exact match 질의와 Range 질의로 나뉜다. 색인을 유지 및 사용하는 것은 추가적인 디스크 접근을 요구하기 때문에 어떤 경우에는 색인을 사용하지 않는 것이 나을 수도 있다.

DBMS는 질의 처리를 위해 몇 단계를 거친다. 더 빠른 수행을 위해 훌륭한 성능의 최적화 도구가 꼭 필요하다. 각 연산에 있어 가장 중요한 성능 척도는 디스크 접근 횟수이다. 메모리 내의 연산에 비해 디스크 접근이 압도적으로 느리기 때문이다. 주요 질의 연산으로 선택 연산, 정렬 연산, 조인 연산이 있다. 질의는 관계 대수로 변환되는데, 각 관계 대수는 자신의 처리 결과를 상위 수준 관계 대수에게 제공한다.



지금까지 데이터베이스 사용과 개발에 있어 생각해봐야 할 점들에 대해 알아보았다. 매 항목이 방대한 내용이지만 짧은 글에서 모두 설명할 수는 없기 때문에 참고 자료를 많이 링크하였다. DB 시스템을 직접 개발할 일이 없더라도 시스템이 어떻게 만들어지는지 알아두는 것은 시스템을 잘 사용하기 위한 능력에 도움이 된다고 본다. 다음 편에서는 통계 기반 데이터 마이닝을 다룬다.

컴퓨터 네트워크(Computer Network)는 컴퓨터와 컴퓨터를 통신망으로 연결한 것을 말한다. 흔히 컴퓨터 네트워크를 배울 때는 OSI 모형(Open Systems Interconnection Reference Model)을 기반으로 공부한다. OSI 7계층으로 많이들 알고 있는데, 각 계층은 하위 계층의 서비스를 받으면서 상위 계층에게 서비스를 제공한다. 먼저 OSI 1. 물리계층에서 4. 전송 계층까지 살펴본 뒤, 리눅스 환경에서 5. 세션 계층부터 7. 응용 계층까지의 사용에 대해 알아보도록 하겠다.

출처 : http://nhprice.com/what-is-ios-model-the-overall-explanation-of-ios-7-layers.html




개요

컴퓨터 네트워크에서 목표는 간단하다. 컴퓨터로부터 다른 컴퓨터로 데이터를 전송하는 것. 목표는 간단하지만 실제로는 매우 복잡한 과정을 거친다. 전송 계층에서 신뢰성 있는 전송 서비스를 제공하는 것, 네트워크 계층에서 네트워크 노드 간의 라우팅 서비스를 제공하는 것, 데이터 링크 계층에서 물리적으로 연결된 노드에게 데이터를 전송하는 서비스를 제공하는 것, 물리 계층에서 실제로 신호로 비트를 전송하는 서비스를 제공하는 것이 잘 이뤄져야 비로소 호스트(host)에게 데이터가 전송된다. 이 글에서는 물리 계층과 데이터 링크 계층은 간략하게만 언급하고 네트워크 계층, 전송 계층과 그 상위 계층에 대해 자세히 다루려고 한다.

네트워크 쪽에는 별도의 용어가 많기 때문에 용어를 먼저 정리해보자.

컴퓨터 네트워크(Computer Network) : 컴퓨터와 컴퓨터를 통신망으로 연결한 것

노드(Node) : 컴퓨터 네트워크상에 연결된 장치

호스트(Host) : 고유 IP 주소를 가진 노드

링크(Link) : 물리적으로 노드와 노드를 연결하는 통로

홉(Hop) : 거리의 단위. 보통 한 링크를 이동하면 한 홉이라고 한다.

경로(Path) : 네트워크 상의 두 노드 간의 이동 경로

프로토콜(Protocol) : 데이터 통신을 원활하게 하기 위해 필요한 통신 규약

전송 계층에서 제공하는 서비스는 신뢰성 있는 통신인 TCP(Transmission Control Protocol)와 신뢰성이 없는 통신인 UDP(User Datagram Protocol)로 나뉜다. 응용 프로그램이 소켓을 통해 보내는 데이터 단위는 메세지(Message), TCP 통신에서 데이터 단위는 세그먼트(Segment), UDP 통신에서 데이터 단위는 데이터그램(Datagram)이라고 한다. 네트워크 계층에서는 패킷(Packet)이라는 데이터 단위를 사용하고, 데이터 링크 계층에서는 프레임(Frame), 마지막으로 물리 계층에서는 비트(Bit) 단위로 전송한다. 이 단위는 프로토콜 데이터 단위(Protocol Data Unit)로 알려진 것인데, OSI 모델에서의 단위이다. 인터넷 프로토콜 스위트(Internet Protocol Suite)에서는 전송 계층에서의 단위를 세그먼트, 네트워크 계층에서의 단위를 데이터그램, 네트워크 접근 계층에서의 단위를 프레임으로 부른다.

각 계층에서는 하위 계층으로부터 문제없이 서비스를 잘 받고, 실제로 어떻게 작동하는지는 알 필요가 없다는 가정 하에 진행된다. 예를 들어 전송 계층에서는 목적지까지 데이터를 잘 전송하는 서비스를 제공하는 것이 목표인데, 하위 계층의 역할인 패킷 경로 제어에 대해서는 관심을 두지 않는다. 이러한 원칙은 투명성(Transparent)으로 널리 알려져 있다.



연결 지향(Connection Oriented) 프로토콜과 비연결(Connectionless) 프로토콜

통신 연결이 유지되는 것을 지향하는 프로토콜을 연결 지향 프로토콜, 연결을 유지하지 않는 프로토콜을 비연결 프로토콜이라고 한다. 연결 지향 프로토콜은 연결을 계속 유지하기 위한 비용이 들기 때문에 더 비싼 반면 비연결 프로토콜은 연결 유지 비용이 들지 않기 때문에 저렴하다. 예를 들어 전화 연결은 연결된 상태를 유지하기 때문에 연결 지향 프로토콜이라고 볼 수 있다. 비싸다고 무조건 나쁜 것이 아니고, 싸다고 무조건 좋은 것이 아니기 때문에 적절한 선택을 해야 한다.

IP 프로토콜은 비연결 프로토콜이지만 IP 프로토콜을 이용하는 TCP 프로토콜은 연결 지향 프로토콜이다. 또다시 TCP 프로토콜을 이용하는 HTTP 프로토콜은 비연결 프로토콜이다. 이렇듯 프로토콜을 어떻게 활용하느냐에 따라 연결 지향과 비연결 프로토콜로 바뀔 수 있다.

연결 지향 프로토콜에서는 이미 연결되어 있기 때문에 어떤 사람이 질의를 보냈는지 연결을 이용하여 알 수 있다. 위에서 예를 든 전화 통화는 이미 통화 연결이 성립될 때 서로 누군지 알기 때문에 연결이 유지되어 있기만 하면 시간이 지난 뒤 다시 말을 해도 누군지 알 수 있다. 하지만 비연결 프로토콜은 매번 새롭게 연결이 성립되기 때문에 필요한 경우 매 연결 시 자신이 누구인지 알려줘야 한다. 예를 들어 HTTP 프로토콜을 이용한 웹 환경의 경우 쿠키나 세션을 통해 매번 자신을 식별할 수 있는 정보를 함께 전송한다. 



전송 계층(Transport Layer)

전송 계층의 역할은 목적지까지 데이터를 잘 도착하도록 하는 것이다. 연결 지향 데이터 스트림 지원, 신뢰성 있는 데이터 전송, 흐름 제어, 그리고 다중화와 같은 편리한 서비스를 제공한다. 전송 계층에서 가장 널리 알려진 프로토콜이 바로 TCP, UDP 프로토콜이다. 앞서 나열한 편리한 서비스들은 거의 다 TCP 프로토콜로 제공되는 것이다. 이러한 편리한 서비스들을 제공하기 위해서는 복잡한 처리를 요구하고, 이는 TCP 프로토콜을 이용하는 비용이 크다는 것을 뜻한다. 때로는 이러한 서비스들이 필요 없는 경우도 있다. 그런 때에는 UDP 프로토콜을 이용하여 저비용으로 통신할 수도 있다.

TCP 프로토콜은 호스트에서만 작동하고, 중간 라우터 노드들에서는 작동하지 않는다. 앞서 언급했듯이 TCP 프로토콜은 복잡한 처리를 요구하기 때문에 모든 중간 라우터 노드들에서까지 TCP 프로토콜을 작동하게 한다면 지금처럼 많은 데이터 전송을 처리할 수가 없기 때문이다.



TCP 프로토콜

TCP 프로토콜의 기능은 신뢰성 있는 데이터 전송(Reliable Data Transfer, RDT), 연결 제어(Connection Control), 흐름 제어(Flow Control), 혼잡 제어(Congestion Control)가 있다. 만약 은행 서비스를 이용하는데 금액에 대한 데이터 전송이 잘못되어 1억 원을 보낸 것이 1만 원을 보냈다고 처리된다면 재앙과도 같을 것이다. TCP 프로토콜의 신뢰성 있는 전송 기능이 있기 때문에 TCP 프로토콜 기반의 통신은 전송자가 보낸 데이터를 수신자가 그대로 전송받는다고 믿을 수 있다. TCP 프로토콜로 전송을 시작하는 것부터 각 기능을 제공하기 위한 자세한 방법을 알아보자.

출처 : http://www.techrepublic.com/article/exploring-the-anatomy-of-a-data-packet/



Multiplexing/Demultiplexing

응용 프로그램이 소켓을 통해 데이터를 전송하는 것부터 TCP 프로토콜이 시작된다. 한 컴퓨터에서 여러 개의 소켓과 프로세스가 존재할 수 있기 때문에 OS에서는 소켓과 프로세스를 식별하기 위해 포트(port) 번호를 따로 둔다. 한 서버에서 여러 소켓과의 연결을 유지하는 것을 생각해보면 왜 이러한 기능이 필요한지 알 수 있을 것이다. 그러나 보통 컴퓨터에 연결된 통신 링크는 하나이기 때문에 하나의 링크를 통해 여러 소켓의 데이터를 주고받아야 한다. 이를 위해 OS에서는 전송하려는 데이터를 TCP/UDP 세그먼트로 만들 때 헤더를 추가하여 공통적으로 출발지 포트 번호와 목적지 포트 번호를 둔다. 이러한 작업을 Multiplexing이라 하고, 목적지에 도착한 세그먼트는 헤더를 확인하여 Demultiplexing 된 뒤 대상 소켓에게 데이터를 전달한다. 편지를 보낼 때 주소뿐만 아니라 그 주소의 누구에게 보내는 것인지도 함께 명시하는 것을 생각하면 이해가 쉬울 것이다.

출처 : http://www.tcpipguide.com/free/t_TCPIPProcessesMultiplexingandClientServerApplicati-2.htm



신뢰성 있는 통신

신뢰성 없는 통신을 기반으로 신뢰성 있는(오류가 없는) 통신을 지원하기 위해 단계적으로 가정을 줄이고 상황을 일반화시켜나간다. 신뢰성 있는 통신을 한다는 것은 잘못된 데이터를 전송받지 않고, 데이터의 순서 또한 유지되는 통신을 보장한다는 뜻이다. 이 항목에서 설명하는 내용은 다음 링크의 내용을 정리한 것이고, 사진들 모두 링크에 있는 것이다. 아래 그림은 신뢰성 있는 통신 서비스를 어떻게 제공하는지 보여주는 그림이다. 신뢰성 있는 데이터 전송 함수인 rdt_send 함수는 내부적으로 신뢰성 없는 채널을 이용하는 udt_send 함수를 이용하여 구현된다.

Reliable data transfer: service model and service implementation

첫 시작인 rdt1.0에서는 신뢰성 있는 채널을 통해 데이터 전송이 이뤄진다고 가정한다. 데이터 전송에 에러가 발생하지 않는다는 가정 하의 전송이기 때문에 rdt_send 함수는 받은 데이터를 패킷으로 만들고 udt_send 함수로 패킷을 전송하기만 해도 신뢰성 있는 통신이 된다. 물론 수신자 측은 받은 패킷을 합쳐 데이터로 되돌리면 된다.

rdt1.0 - a protocol for a completely reliable channel

rdt2.0에서는 비트 에러가 존재할 수 있는 상황까지 수용한다. 전화 통화에서 받아쓰기를 한다고 생각해보자. 아마 상대방이 한 문장씩 말할 때마다 잘 들었다는 응답(OK) 혹은 다시 말해달라는 응답(Please repeat that)을 통해 받아쓰기를 잘 하고 있는지 확인할 수 있을 것이다. rdt2.0에서는 이 방식을 모티브로 삼아 작동하며, 이러한 응답을 통한 통신 조절을 ARQ(Automatic Repeat reQuest)라고 한다. 

rdt2.0에서는 여기에 두 가지 추가적인 기능이 요구된다. 첫 번째로 신뢰성 없는 채널을 통한 통신이 이뤄지기 때문에 비트 에러가 있는지 감지할 수 있어야 한다. 이 감지를 위한 방법으로는 체크섬(checksum)이라는 훌륭한 방법이 있다. 두 번째로 수신자로부터 피드백을 받을 수 있어야 한다. 송신자는 수신자의 어떠한 피드백 없이는 전송이 어떻게 이뤄졌는지 알 수 없다. 이 피드백을 위해 수신자는 송신자로부터 데이터를 성공적으로 받았을 때 ACK, 잘못 받았을 때 NA(C)K를 보낸다. 아래 사진을 보면 수신자 측에서 패킷을 성공적으로 받았을 때 패킷이 손상되었다면(corrupt) NACK를 보내고, 패킷이 손상되지 않았다면(notcorrupt) ACK를 보내는 것을 확인할 수 있다. 이런 식으로 송신 후 기다리기 때문에 rdt2.0은 stop-and-wait 프로토콜로 알려져 있다.


rdt2.0 -  a protocol for a channel with bit-errors

그러나 ACK와 NAK 응답도 에러가 발생할 수 있다. ACK도 NAK도 오지 않으면 송신자 측에서는 무한정 기다리게 된다. 송신 측에서는 에러가 났는지 알 수가 없다. 또한, 중복 송신을 하게 되면 중복 수신을 하게 된다.

rdt2.1에서는 순서 번호(Sequence number)를 추가함으로써 중복 수신을 방지한다. 순서 번호만 확인하면 중복된 데이터를 받았는지 알 수 있으므로 중복 수신을 방지할 수 있다. stop-and-wait 프로토콜 상으로는 방금 전송한 패킷이 잘 전송되었는지만 알면 되기 때문에 순서 번호가 0 혹은 1만 있으면 된다. 아래 그림은 rdt2.1을 나타낸 그림이다. 0에 대한 패킷 전송 완료 후에는 1에 대한 패킷 전송을 하고, 그 후에는 다시 0에 대한 패킷을 전송한다. rdt2.0의 가정상 아직 패킷이 유실될 수 있는 상황은 아니기 때문에 이 방법은 문제가 없다.

여기서 잘못된 수신에 대한 NAK 응답이 아닌 ACK를 이용한 방법이 가능하다. rdt2.2는 NAK를 사용하지 않는 방법이다. ACK에 순서 번호를 추가함으로써 잘못된 데이터를 받은 것을 다시 송신하게 할 수 있다.

이제는 심지어 패킷이 사라질 수도 있는 채널을 통해 통신한다고 가정하자. 이 경우엔 패킷 손실을 어떻게 감지할지, 그리고 패킷 손실이 발생하면 어떻게 해야 할지 생각해봐야 한다. 패킷 손실이 됐을 때 대응 방법은 이미 rdt2.2까지의 고민을 통해 알 수 있다. 다시 보내는 것. 쉽고 간단한 방법으로 해결된다. rdt3.0에서는 송신자가 ACK를 받는 것에 대한 타임 아웃 타이머를 둬서 이를 해결한다. 시간 내에 데이터를 성공적으로 수신했다는 응답을 받지 못하면 데이터를 다시 보내는 것이다. 중복된 데이터 수신에 대해서는 이미 rdt2.2 상에서 처리된다.

rdt3.0까지 발전시켜나감으로써 이제 신뢰성 있는 통신을 할 수 있게 됐다. 하지만 이 방법은 한 번에 한 패킷씩 밖에 보내지 못하기 때문에 성능상으로 만족스럽지 못하다. 매우 먼 거리에 패킷을 보내려 한다면 패킷이 왔다 갔다(Round trip) 하는 것을 기다리는 시간 때문에 전송 속도가 매우 떨어질 것이다. 이제는 여러 패킷을 보낼 방법을 고민해야 한다. ACK를 받기 전에 다수의 패킷을 전송하는 방법을 통틀어 파이프라인 프로토콜이라고 하며, 세부적으로 GBN(Go-Back-N)SR(Selective Repeat) 프로토콜이 있다.

Go-Back-N 프로토콜은 수신자 측에서 지금까지 성공적으로 받은 패킷 순서 번호에 대한 ACK를 전송하며, 순서에 맞지 않는 패킷은 성공적으로 수신한 것으로 간주하지 않는다. 따라서, 송신자는 ACK를 받지 못한 가장 오래된 패킷부터 모두 재전송하게 된다. Selective Repeat 프로콜은 잘못된 순서의 패킷을 받았더라도 버퍼에 저장해둔다. 중간에 수신 실패한 패킷만 다시 전송하게끔 수신자 측은 개별적으로 ACK를 전송하며, 송신자 역시 패킷마다 타임 아웃 타이머를 가진다. Selective Repeat 프로토콜상으로는 상위 계층에게 현재 성공적으로 수신한 패킷까지만 제공함으로써 해당 패킷까지 신뢰성 있게 통신되었음을 보장한다. 아래 사진은 GBN 프로토콜과 SR 프로토콜을 요약한 사진이다.

Go-Back-N Protocol
Selective Repeat Protocol

이로써 신뢰성 없는 채널을 통한 신뢰성 있는 통신을 구현하는 방법에 대해 알아보았다. 실제 TCP에서는 Go-Back-N 프로토콜과 Selective Repeat 프로토콜을 하이브리드하여 사용된다고 한다.



적절한 타임 아웃 시간 예측

여기서 의문이 생길 수 있는 부분은 타임 아웃 시간은 어떻게 결정하는가이다. 타임 아웃 시간을 너무 짧은 시간으로 두면 재전송 요청을 많이 하게 되고, 너무 긴 시간으로 두면 기다리는 시간이 길어진다. 적절한 타임 아웃 시간을 설정하기 위한 방법으로 그때그때 확인된 RTT(Round Trip Time, 패킷이 갔다 오는데 걸린 시간)인 SampleRTT를 이용하여 다음 RTT인 EstimatedRTT를 예측한다. 원리는 이전까지의 RTT와 다음 RTT는 비슷할 것이고, 변화가 생기면 소폭 반영하는 것이다. 식으로 나타내면 다음과 같다. 

nextEstimatedRTT = (1 - a)*previousEstimatedRTT + a*sampleRTT

적절한 a, 예를 들면 0.125 정도를 설정하면 과거의 데이터에 더 비중을 둬서 변화를 수용하게 된다.



연결 제어

TCP 프로토콜은 연결 지향 프로토콜이고, 신뢰성 있는 통신을 제공하기 위하여 순서 번호를 사용한다고 하였다. TCP 프로토콜에서 통신이 이뤄지기 위해서는 먼저 서로 연결이 되어야 하고, 서로 임의의 시작 순서 번호를 알려줘야 한다. 그 과정을 3-way handshaking이라고 한다. 다음 박스의 내용은 연결 시작을 위한 3-way handshaking 과정을 보여준다.

Alice ---> Bob    SYNchronize with my Initial Sequence Number of X
Alice <--- Bob    I received your syn, I ACKnowledge that I am ready for [X+1]
Alice <--- Bob    SYNchronize with my Initial Sequence Number of Y
Alice ---> Bob    I received your syn, I ACKnowledge that I am ready for [Y+1]

TCP 프로토콜에서는 연결하자는 뜻으로 SYN 패킷을 보낸다. SYN은 SYNchronize의 약자로 Alice는 SYN 패킷과 함께 초기 순서 번호(Initial Sequence Number, ISN)를 Bob에게 보낸다. Bob은 Alice의 초기 순서 번호가 X인 것을 알게 되고, Alice에게 SYN, ACK가 합쳐진 패킷을 보내면서 자신의 초기 순서 번호가 Y임을 알려준다. 마지막으로 Alice는 Bob으로부터 초기 순서 번호를 잘 받았다는 의미로 ACK를 보낸다. 이 과정을 통해 두 호스트는 신뢰성 있는 통신을 할 수 있는 준비를 마치게 된다.

출처 : http://asfirstalways.tistory.com/356

실제로는 3-way handshaking 과정에서 초기 순서 번호 외에도 수신 기본 윈도우 크기(rwnd), 여러 가지 추가적인 옵션 등을 교환하게 된다. 왜 2-way handshaking으로는 안 되는 걸까? 아래 사진은 2-way handshaking의 실패 시나리오다. 호스트 A가 호스트 B에게 연결 요청을 한 것이 딜레이가 많이 되어 다시 연결 요청을 하는 상황이 발생할 수 있다. 그 경우 호스트 B는 과거의 연결 요청에 대한 순서 번호로 응답을 하게 되고, 호스트 A는 잘못된 순서 번호의 패킷이 왔기 때문에 그 패킷을 버리게 된다. 따라서 성공적으로 통신을 할 수 없게 된다.

2-way handshaking 연결 실패 시나리오

이제 연결 종료를 위한 4-way handshaking에 대해 알아보자. 호스트 A가 연결 종료를 하자는 FIN 패킷을 호스트 B에게 보내면 호스트 B가 응답으로 ACK 패킷을 호스트 A에게 보낸다. 호스트 B는 이제 남은 데이터를 모두 전송한 뒤 FIN 패킷을 호스트 A에게 보내고, 호스트 A는 응답으로 ACK 패킷을 호스트 B에게 보낸다. FIN 패킷을 보낸 시점부터 더 이상 데이터 전송이 불가능하다는 것이 핵심이다. 아래 그림은 연결 종료 4-way handshaking 과정을 보여준다.

출처 : http://imada.sdu.dk/~jamik/dm543-14/material/chapter3.html#TCP:-closing-a-connection



흐름 제어와 혼잡 제어

송신량과 수신 처리량을 일치시키는 것을 흐름 제어라고 한다. 수신 측이 수신 가능한 양보다 더 많은 데이터를 전송하려 해봤자 수신 측의 버퍼에 남은 공간이 없다면 힘들게 전송한 데이터를 버리게 된다. 그럴 바에는 수신 측이 처리할 수 있는 양만큼만 전송하도록 제어하는 서비스가 흐름 제어다. 이를 위해서는 수신 측에서 송신 측에게 데이터를 다 처리하고 있지 못하다는 피드백을 줄 수 있는 방법이 필요하다. 이를 위해 크게 Stop and wait 방식과 Sliding Window 방식이 사용된다. Stop and wait 방식은 1개씩 프레임을 전송하는 방식이기 때문에 효율성이 나쁘고 거의 이용되지 않으므로 Sliding Window 방식을 알아보자.

수신 측은 여유 버퍼 공간 크기를 rwnd(TCP 패킷 헤더의 Window Size 필드)라는 값으로 송신 측에게 알려준다. 송신 측은 마지막으로 보낸 데이터 순서 번호(LastByteSent)에서 마지막으로 성공적으로 송신한 데이터 순서 번호(LastByteAcked)를 뺀 것이 rwnd를 넘지 않도록 데이터를 보낸다. 이 방식은 윈도우 기반의 방식이라는 점에서 신뢰성 있는 데이터 전송을 위한 Selective Repeat 방식과 비슷하다고 볼 수 있다.

반면에 네트워크가 혼잡하다고 판단될 때 데이터 송신량을 떨어뜨리는 것을 혼잡 제어라고 한다. 네트워크망이 처리 가능한 데이터량을 넘어서는 데이터를 전송하려 하면 결국 초과되는 양의 데이터는 전송에 실패하게 되고, 신뢰성 있는 데이터 전송 알고리즘에 의해 전송 실패한 데이터를 다시 전송시도하게 된다. 이러한 악순환이 반복되어 네트워크망이 혼잡해지는 문제가 일어나지 않도록 혼잡 제어 서비스를 제공한다. 

혼잡 상태가 일어났을 땐 보통 라우터의 버퍼가 오버플로우 되어 패킷이 유실되거나 라우터의 버퍼에 대기함으로 인해 평소보다 큰 딜레이가 발생되는 두 가지로 나뉜다. 두 경우 모두 ACK를 받지 못하는 패킷에 대한 타임 아웃으로 알 수 있게 된다. AIMD(Additive Increase/Multiplicative Decrease), Slow Start, Fast Retransmit, Fast Recovery 등 몇 가지 방식들이 있지만 공통적으로 송신 측의 전송 버퍼의 크기 cwnd를 차례로 키워나가다가 전송 실패가 발생할 경우 전송량을 줄인다는 점은 비슷하다. rwnd는 수신 측이 알려주는 반면 cwnd는 송신 측이 앞서 예를 든 방식들을 이용하여 적절한 값을 찾아나간다.

결국 흐름 제어와 혼잡 제어 모두 송신 측이 데이터 전송량을 조절해야 한다는 점에서는 같다. 결과적으로 rwnd보다 많이 데이터를 전송해서도, cwnd보다 많이 데이터를 전송해서도 안 되기 때문에, rwnd와 cwnd 중 작은 값만큼의 양을 전송하면 된다.



UDP 프로토콜

지금까지 알아본 TCP 프로토콜에 비해 UDP 프로토콜은 매우 간략하다. TCP 프로토콜에서 제공하는 Multiplexing/Demultiplexing, 신뢰성 있는 데이터 전송, 연결 제어, 흐름 제어, 혼잡 제어 등의 서비스를 제공하지 않으면 UDP 프로토콜이라고 볼 수 있다. 이러한 서비스들을 제공하지 않기 때문에 부하(Overhead)가 매우 적다. 그렇기 때문에 많은 요청을 처리해줘야 하고, 신뢰성이 떨어져도 괜찮은 서비스들에서 유용하게 쓰일 수 있다. 대표적인 예로 DNS(Domain Name Service), VoIP(음성 인터넷 프로토콜), 온라인 게임 서버 등에서 쓰인다. 

출처 : https://ssophiz.blogspot.kr/2016/12/blog-post_30.html

UDP 프로토콜에서는 수신자가 메세지를 수신했는지 알 수 없고, 보낸 순서대로 받도록 하지도 않는다. 이러한 기능이 필요하다면 상위 프로토콜 상의 처리가 필요하다. 예를 들어 DNS 질의 요청에 대한 응답이 일정 시간 내에 오지 않으면 다시 질의를 보낸다. 실시간 스트리밍이라면 오래된 순서 번호의 패킷은 그냥 버린다. 어차피 과거의 패킷이므로 굳이 사용할 필요가 없기 때문이다.



전송 계층 정리

전송 계층에서는 소켓으로 어떻게 데이터를 잘 전달할 것인지에 대해 다뤘다. 비용이 더 들긴 하지만 보낸 데이터를 그대로 받을 수 있도록 해주는 TCP 프로토콜을 이용하거나, 낮은 비용으로 데이터를 보낼 수 있지만 신뢰성이 떨어지는 UDP 프로토콜을 선택할 수도 있다. 모든 경우에 적합한 방법은 없기 때문에 문제에 따라 적절한 선택을 해야 한다. 프로토콜을 통해 프로세스에게 데이터를 잘 전달해주는 것은 OS가 도와준다. 받은 데이터를 어떻게 활용하는지는 응용 계층에게 달려있다.



네트워크 계층(Network Layer)

네트워크 계층의 역할은 전송 계층에서 보내려는 패킷을 목적지 노드까지 도착시키는 것이다. 네트워크 계층에서는 중간 라우터를 통한 라우팅(Routing)과 포워딩(Forwarding), 필요하다면 연결 설정을 담당한다. 가능한 빠르게 데이터를 전송하기 위해서 최적의 경로로 데이터를 전송해야 하지만 그것이 쉬운 일이 아니다. 이러한 일을 해주는 IP 프로토콜에 대해서 알아볼 것이다.

라우팅과 포워딩의 차이를 알아보자. 라우터는 또 다른 라우터들과 물리적으로 연결되어 있다고 보면 된다. 라우팅은 현재 라우터에서 연결된 라우터 중 어떤 라우터로 보내는 것이 목적지로 가는데 최적인지 결정하는 것이다. 라우팅 알고리즘이 수행되고 나면 어떤 헤더를 가진 패킷을 어떤 링크로 전송하라는 정보가 정리된 포워딩 테이블이 만들어진다. 만들어진 포워딩 테이블을 기반으로 라우터는 전달받은 패킷을 다른 라우터로 포워딩한다.

네트워크 계층의 ATM, Frame Relay, X.25와 같은 모델들은 다른 라우터까지의 대역폭을 예약해둘 수도 있다. 데이터 그램이 전송되기 전 두 호스트 간의 경로를 가상 연결(Virtual Connection)로 설정하여 두 호스트가 충분한 서비스를 제공받을 수 있도록 한다. 이 글에서는 인터넷 모델을 다루기 때문에 이에 대한 설명을 생략하고, 라우팅에 대해서만 자세히 다루고자 한다.

우리가 관심 있는 네트워크 계층의 프로토콜은 크게 라우팅 프로토콜, 인터넷 프로토콜, ICMP 프로토콜로 나뉜다. 라우팅 프로토콜은 포워딩 테이블을 만들기 위한 프로토콜이고, 인터넷 프로토콜은 주소 체계를 위한 프로토콜, ICMP 프로토콜은 에러 메세지를 전달받기 위해 주로 쓰이는 프로토콜이다.



데이터그램 네트워크(Datagram Network)

데이터그램이 전송되기 전 연결을 먼저 설정하는 방식과 달리 데이터그램 네트워크에서는 그때그때 받은 데이터그램의 목적지 주소를 확인하여 포워딩 테이블에 따라 출력 링크로 전송한다. 포워딩 테이블의 각 항목은 주소 범위와 출력 링크를 가진다. 왜 주소 범위인지 궁금하다면 IPv4의 주소 체계는 2의 32승, 40억 개가량의 항목을 가질 수 있기 때문임을 생각해보면 된다. 만약 범위가 아니라 각 주소로 포워딩 테이블을 만든다면 테이블의 크기가 너무 커서 현실적이지 않을 것이다.

출처 : http://www.geeksforgeeks.org/computer-networks-longest-prefix-matching-in-routers/

받은 데이터그램에 대해 포워딩 테이블에 있는 엔트리 중 어떤 것을 선택할지 결정하기 위해 Longest Prefix Matching 방법이 이용된다. 개념적으로 생각해보면 더 세부적으로 결정되어 있는 것을 따르는 선택 규칙이라고 보면 된다.



인터넷 프로토콜(Internet Protocol, IP)

인터넷 프로토콜은 호스트의 주소 지정과 패킷 분할 및 조립 기능을 담당한다. 데이터 링크 계층에서 전달할 수 있는 데이터의 최대 크기를 MTU(Maximum Transmission Unit)이라고 하는데, 전송하고자 하는 데이터그램이 MTU보다 크다면 데이터그램을 분할하여 offset을 지정해준다. 분할된 데이터그램들은 최종 목적지에서 재조립된다.

출처 : http://www.techrepublic.com/article/exploring-the-anatomy-of-a-data-packet/

전송 계층 프로토콜들의 패킷에서는 포트 번호까지만 나타났지만 인터넷 프로토콜부터는 IP 주소가 나타난다. 우리가 익숙하게 알고 있던 192.168.0.1 같은 주소는 IPv4 주소 체계에 의한 주소이다. 인터넷 프로토콜은 과거부터 꾸준히 발전되어 왔는데, IPv4는 전 세계적으로 사용된 첫 번째 인터넷 프로토콜이다. 현재는 주소 공간의 고갈로 인해 IPv6로 대체되고 있다. 개념상의 이해를 쉽게 하기 위해 설명은 IPv4를 기준으로 하겠다. IPv6에서 IPv4 프로토콜이 어떻게 이용되는지는 IPv6 링크의 IPv6 전환 기술을 참고.

네트워크망 상에서 호스트 또는 라우터 간의 물리 링크 연결을 인터페이스(Interface)라고 한다. 먼저 IP 주소는 호스트에 부여되는 것이 아닌 인터페이스에 부여되는 것이라는 사실을 알아둬야 한다. 라우터는 보통 여러 노드들과 연결되어 있으므로 여러 개의 인터페이스를 갖고 있고, 호스트는 보통 하나의 유선 연결이나 무선 연결이 되어 있으므로 하나 혹은 두 개의 인터페이스를 가진다. 실제 네트워크 망에서 모든 호스트가 라우터와 직접 연결되어 있진 않고, 중간에 이더넷 스위치(Ethernet Switch)라는 것들이 여러 개의 호스트와 연결되어 있고, 이더넷 스위치와 라우터가 연결되어 있다. 

IP 주소로 표현 가능한 주소가 매우 많기 때문에 관리의 효율성을 위해 서브넷이라는 개념이 도입된다. 이에 따라 IP 주소는 서브넷 부분(상위 비트), 호스트 부분(하위 비트)으로 나뉘게 된다. 서브넷에 대해 다음 항목에서 좀 더 자세히 알아보자.



서브넷(Subnet)

IP 주소의 서브넷 부분이 같은 노드들을 모아 서브넷이라고 한다. 정의가 순환 의존 관계에 있는 것 같다... 다르게는 중계하는 라우터 없이 물리적으로 연결된(이더넷 스위치 포함) 네트워크망이라고 말할 수 있다. 서브(sub)와 네트워크(network)의 합성어인 것을 보면 알겠지만 계층적으로 하위에 존재하는 네트워크이다. 

서브넷을 활용하는 것은 관리상의 이점을 가진다. 우리나라에서 중앙 정부가 모든 부분을 완전 통제하지 않고 도를 나누고, 또 도 안에서도 시를 나누어 관리하듯이 네트워크망도 계층을 나누어 관리하는 것이 더 수월하다. 이처럼 하위 네트워크망(서브넷)의 관리를 맡겨버리는 방법을 서브네트워킹(Subnetworking)이라고 한다.

출처 : http://www.orbit-computer-solutions.com/variable-length-subnet-mask-vlsm/

서브넷을 활용하기 위해서는 IP 주소에서 어디까지가 서브넷 부분인지 알려줄 필요가 있다. "223.1.1.0/24"와 같은 표현은 "223.1.1.0" 주소의 상위 24bit가 서브넷 부분임을 알려주고, "/24"를 서브넷 마스크라고 한다. 아마 네트워크 설정에서 아마 한 번쯤 봤을 것이다. 상위 24bit가 서브넷 부분이므로 이를 위한 서브넷 마스크를 주소로 표현하면 "255.255.255.0"이 되고, 서브넷 마스크와 IP 주소를 Bitwise AND 연산하면 서브넷 부분만 얻을 수 있게 된다.

클래스 주소 체계는 IP 주소의 서브넷 부분을 정확히 8bit, 16bit, 24bit로 나누는 방식이다. 따라서 A 클래스는 "/8", B 클래스는 "/16", C 클래스는 "/24"를 서브넷 마스크로 가진다. 그러나 이 방법은 C 클래스는 최대 254개 호스트, B 클래스는 최대 65534개의 호스트를 가질 수 있는 것에서 볼 수 있듯이 유연성이 떨어진다. 대신 "a.b.c.d/x"와 같은 형식으로 서브넷 부분이 임의의 길이를 가질 수 있도록 하는 방법을 CIDR(Classless Inter Domain Routing)이라고 한다.



호스트 주소와 주소 블록 획득

지금까지의 공부로 IP 주소가 어떤 것인지 대충 이해했다. 그렇다면 호스트는 어떻게 IP 주소를 부여받을 수 있는가? 기본적으로 고정 IP와 유동 IP로 알려진 두 방법이 있다. 고정 IP는 특정 IP를 아예 호스트에 지정해버리는 방법이고, 유동 IP는 IP 주소를 동적으로 획득하는 방법이다. 네트워크에 연결됐을 때만 주소를 가지므로 IP 주소를 재활용할 수 있기 때문에 큰 장점이 있다. 특히 요즘은 모바일 기기로 잦은 네트워크 접속과 종료가 있는 상황에서는 더욱 유용하다. 유동 IP를 지원하는 가장 대중적인 방법은 DHCP(Dynamic Host Configuration Protocol) 프로토콜을 사용하는 것이다. 아마 네트워크나 공유기 설정에서도 쉽게 찾아볼 수 있을 것이다. DHCP 프로토콜에 대해 좀 더 자세한 설명은 다음 링크를 참고.

기관에서 여러 호스트로 이뤄진 서브넷을 만들어 관리하고 싶다면 주소 블록이 필요하다. 주소 블록은 어떻게 획득할까? 답은 "인터넷 서비스 공급자 ISP(Internet Service Provider)에게 돈 주고 산다"이다. ISP는 더 상위의 IP 주소 관리 단체(Internet Corporation for Assigned Names and Numbers, ICANN)로부터 주소 블록을 발급받는다. 그리고 발급받은 주소 블록을 쪼개어 고객들에게 주소 블록을 판다. 



Hierarchical Addressing

출처 : 서창진 교수님 강의 노트

ISP는 인터넷 망에게 자신의 주소 블록을 목적지로 하는 패킷들을 모두 보내라고 광고한다. 넓은 서브넷상으로 라우팅 된 패킷이 ISP 망 내에 도착하면 또다시 더 좁은 서브넷으로 라우팅 되기를 반복할 것이다. 이러한 방식을 Hierarchical Addressing이라고 한다. 

그런데 만약 어떤 기관이 다른 ISP로 이전한다면? 이전한 ISP가 아예 다른 주소 블록을 라우팅 하도록 광고하고 있었다면 Hierarchical Addressing으로 해당 기관에게 라우팅 하지 못하게 된다. 이 경우 ISP는 인터넷 망에게 추가적인 주소 블록도 광고한다. 더 좁은 서브넷은 서브넷의 길이가 더 길어질 것이므로 앞서 언급했었던 Longest Prefix Matching 방법에 의해 적절한 ISP로 라우팅 될 것이다.



NAT(Network Address Translation)

NAT는 IP 주소 하나를 갖고 여러 호스트를 관리하는 방법이다. NAT 장비 내부에는 지역 네트워크가 형성되고, 지역 네트워크 상의 주소를 NAT 장비가 관리한다. 지역 네트워크에서 패킷이 외부로 나갈 때 NAT 장비를 거치게 되고, 이때 패킷의 출발 주소가 NAT 장비의 주소로 변경된다. 하나의 IP로 여러 호스트를 관리하기 때문에 NAT 장비는 출력하는 패킷의 포트 번호를 다른 번호로 바꿔버린다. 이렇게 하면 나중에 지역 네트워크로 진입하고자 하는 패킷의 목적지 포트 번호를 확인하여 적절한 지역 네트워크 호스트를 찾을 수 있게 된다. 

NAT는 IP 주소를 효율적으로 사용할 뿐만 아니라 내부 장비들의 IP 주소를 숨김으로써 보안상의 이점도 가진다. 멀리 찾을 필요 없이 우리에게 가장 가까운 NAT 장비는 무선 기지국이다. 스마트폰이 셀룰러 네트워크에 연결되는 것이 기지국이라는 NAT에 연결되는 것이라고 보면 된다. NAT는 그 자체로도 복잡한 주제이므로 더 자세히 다루지 않겠다. 대신 홀 펀칭이라는 흥미로운 NAT 통과 기법을 소개한다. NAT는 내부에서 외부로 나가는 패킷이 발생하지 않으면 외부에서 내부로 들어오는 패킷을 막아버린다는 점에서 P2P 연결을 어렵게 하는데, 홀 펀칭 기법은 이를 해결하는 기법이다.



ICMP(Internet Control Message Protocol)

ICMP 프로토콜은 호스트와 라우터 사이의 네트워크 계층 정보를 통신하기 위해 사용되는 프로토콜이다. ping, traceroute 프로그램에서 쓰이는 것이 익숙하다. 특히 traceroute는 어떤 경로를 거쳐 호스트에 도착하는지 확인할 수 있어서 네트워크 관련 문제를 디버깅하고자 할 때 유용하게 쓰이곤 한다. 각 프로그램의 더 자세한 설명은 찾아보기 쉬우니 따로 더 다루지 않는다.



라우팅 알고리즘

라우팅 알고리즘은 크게 Link State, Distance Vector, Hierarchical Routing 세 가지로 나뉜다. Link State는 주기적으로 링크의 비용을 주변 라우터들에게 알려주면(Link state broadcast) 최소 비용 경로를 다시 계산하는 방식이다. 그 익숙한 다익스트라의 최단 경로 알고리즘이 쓰인다. 라우터가 모든 목적지까지의 경로를 저장해야 하기 때문에 메모리가 많이 소모되고, 복잡한 그래프에 대해 최단 경로 알고리즘을 수행해야 하기 때문에 CPU가 많이 쓰인다. 그러나 모든 목적지까지의 경로를 알고 있으므로 라우팅 테이블 변화에 따른 전파 속도가 빠르다. OSPF(Open Shortest Path First) 프로토콜이 대표적이다. OSPF 프로토콜에 대한 자세한 설명은 다음 링크를 참고.

Distance Vector는 목적지까지의 모든 경로를 저장하지 않고 목적지까지의 거리(Hop count)와 목적지까지 가기 위해 어떤 이웃 라우터를 거쳐야 하는지 방향(Vector)만을 저장하는 방식이다. 이를 위해 벨먼-포드 알고리즘이 사용된다. 적은 정보만을 저장해도 되기 때문에 메모리가 절약되지만 라우팅 테이블에 변화가 생겼을 때 전파되는데 시간이 오래 걸리고, 정해진 시간마다 라우팅 테이블을 업데이트해야 하기 때문에 트래픽 낭비가 크다. RIP(Routing Information Protocol) 프로토콜이 대표적이다. 

Hierarchical Routing은 앞서 언급한 Hierarchical Addressing과 비슷하다. 수많은 목적지에 대해 모두 라우팅 테이블을 저장하고 링크 상태를 알려주는 것은 너무 어렵다. 좀 더 쉽게 문제를 해결하기 위해 인터넷을 AS(Autonomous System)라는 좀 더 작은 구역으로 나눈다. 각 AS는 관리자에 의해 자치적으로 운영되며, 각자의 라우팅 프로토콜을 사용한다. AS 내에서의(Intra-AS) 라우팅 위한 프로토콜을 IGP(Interior Gateway Protocol)라고 하며 OSPF 프로토콜과 RIP 프로토콜이 주로 쓰인다. 반면 AS 외부로의(Inter-AS) 라우팅을 위해서는 먼저 다른 AS로 찾아갈 수 있도록 하는 라우팅이 필요하며, 이를 EGP(Exterior Gateway Protocol)이라고 한다. EGP로는 BGP(Border Gateway Protocol)이 주로 쓰인다.



네트워크 계층 정리

이로써 네트워크 계층에서 주소 관리와 라우팅을 위한 부분들을 알아보았다. 전체 인터넷은 계층적 네트워크로 이루어져 있으며 하위 네트워크를 서브넷이라고 한다. 라우터는 전달받은 패킷의 목적지를 보고 포워딩 테이블에서 가장 적합한 인터페이스를 찾아 그 인터페이스로 패킷을 출력한다. 목적지 전부를 테이블로 만들기 어렵기 때문에 포워딩 테이블은 정확한 목적지 주소가 아닌 서브넷들로 구성되어 있다. 포워딩 테이블을 만들기 위해서 여러 가지 라우팅 알고리즘이 사용된다. 외부의 AS로 이동하기 위한 라우팅과 AS 내에서의 라우팅을 위한 프로토콜로 나뉜다. 공통적으로 라우터들은 서로 정보를 공유하며(Interworking) 최적의 라우팅을 위해 노력한다.



데이터 링크 계층과 물리 계층

개인적으로 소프트웨어 개발을 위해서 데이터 링크 계층과 물리 계층까지 잘 알아야 한다고 생각하지는 않는다. 대신 이러한 일이 있구나 정도만 알고 넘어가면 되지 않을까 싶다. 따라서 이 항목은 간략하게만 쓰려고 한다.

데이터 링크 계층부터는 실제로 인터페이스를 통해 데이터 전송이 이뤄진다. 데이터 링크 계층에서는 흐름 제어(전송 계층과 다르다), 오류 제어, 반이중과 전이중 서비스를 제공한다. 인터페이스는 하나이고 양측이 함께 사용하기 때문에 양측이 골고루 사용할 수 있게끔 조율을 해줘야 공평할 것이다. 또한, 물리 계층에서 데이터 전송 시에는 물리적인 문제로 인해 잘못된 비트가 전송될 수도 있다. 이를 감지할 수 있도록 하는 서비스가 오류 감지이며, 어떤 오류 감지 기법은 수정 기능까지도 가진다. 

데이터 링크 계층에서 사용되는 주소는 MAC(Media Access Control) 주소이다. IP 주소는 바뀔 수 있는 반면 MAC 주소는 네트워크 인터페이스에 할당된 고유 식별자이다. IP 패킷을 전송하기 위해 물리적 네트워크 주소인 MAC 주소가 필요한데, 이를 모를 때 ARP(Address Resolution Protocol) 프로토콜을 이용한다. 

물리 계층에서는 물리적으로 비트를 전송하기 위한 일들을 한다. 그렇기에 당연히 하드웨어와 밀접한 관계에 있을 수밖에 없는 계층이다. 이더넷, 토큰 링 등이 이 계층에 포함되며 전기 신호 혹은 광 통신 등이 있다. 전기 신호는 멀리 이동할수록 약해지는데, 전기 신호를 다시 증폭시키기 위해 리피터가 사용된다. 우리 세대까지만 해도 전화선을 이용하던 것으로 익숙한 모뎀(MOdulator and DEModulator)도 이 계층에 속한다. 모뎀은 이름에 나타나 있듯이 디지털 신호를 아날로그 신호로 바꾸어 전송하고, 아날로그 신호를 다시 디지털 신호로 읽어내는 역할을 했었던 기계이다. 



세션 계층(Session Layer)

지금까지 패킷이 네트워크망을 거쳐 호스트에게 도착하는 여행을 알아보았다. 투명성의 원칙에 따라 호스트에 어떻게든 패킷이 올바르게 도착했다는 가정 하에 시작해보자. 네트워크 망을 통한 통신은 OS에 의해 관리되며, 실제로 우리가 프로그래밍하는 것은 OS 위에서 돌아가는 프로세스이다. 세션 계층은 프로세스가 통신을 관리하기 위한 방법에 대한 계층이다. 또한, TCP/IP 세션을 만들고 없애는 책임을 수행하는 계층이기도 하다. 

프로세스가 OS에게서 데이터를 전달받기 위해서는 앞서 이미 언급한 소켓을 이용한다. 세션 계층은 추상적으로 논리적 연결에 대해 다루는 계층이므로 소켓 외에 명확히 콕 집어 이야기할만한 주제는 거의 없지만, 그 개념 자체는 응용 계층에서 재활용될 것이다.



표현 계층(Presentation Layer)

소켓을 통해 전달받은 데이터는 아직 바이트 덩어리일 뿐이다. 문자만 해도 ASCII 코드 혹은 유니코드 등 다양한 방법으로 표현될 수 있고, 어떤 시스템은 정수 값을 16bit 만으로 표현할 수도 있다. 이렇듯 시스템마다 데이터 표현 방식이 다르기 때문에 이를 같은 데이터로 인식하기 위한 처리를 해주는 계층이 필요하며, 표현 계층이 그 기능을 수행한다. 말하자면 번역기와 같은 일을 하는 계층인 것이다.

압축과 암호화 또한 표현 계층에서의 서비스이다. 압축을 통해 전송하고자 하는 데이터를 좀 더 작게 만들어 보낼 수도 있고, 보안을 위해 암호화된 데이터로 보낼 수도 있다. TLS(Transport Layer Security)는 널리 쓰이고 있는 보안을 위한 암호 규약이다. 또는, RPC(Remote Procedure Call)를 위한 데이터 직렬화 등을 필요로 하기도 한다. XDR(eXternal Data Representation) 프로토콜이 직렬화 프로토콜의 대표적인 예이다.



응용 계층(Application Layer)

표현 계층까지 거쳐 "이해할 수 있는" 데이터가 되었다. 이제 데이터를 주고받는 방법을 이용한 어떠한 일도 할 수 있다. 학부 강의에서 가장 많이 예를 드는 응용 계층의 프로토콜은 전자 메일과 관련된 SMTP(Simple MailTransfer Protocol) 프로토콜, 파일 전송을 위한 FTP(File Transfer Protocol) 프로토콜, WWW(World Wide Web) 세상의 정보를 주고받는 HTTP(HyperText Transfer Protocol) 프로토콜이다. 

특히 HTTP 프로토콜은 가장 널리 쓰인다고 볼 수 있는 프로토콜 중 하나이다. 브라우저는 HTTP 프로토콜을 이용하여 지금 보고 있는 이 웹 페이지를 설명하는 HTML 문서를 받아오고, 함께 쓰인 CSS를 기반으로 페이지를 렌더링 하고, 또한 함께 쓰인 JS를 해석하여 실행함으로써 풍부한 웹 경험을 제공한다. 브라우저의 작동 원리에 대해서는 네이버 D2의 글이 아주 잘 설명되어 있어 한 번 읽어보기를 꼭 권하고 싶다.

꼭 브라우저가 아니더라도 HTTP 프로토콜이 워낙 잘 정의되어 있는 프로토콜이기 때문에 서버와 통신하기 위한 프로토콜을 별도로 정의하지 않고 HTTP 프로토콜을 그냥 이용하는 경우가 많다. 그러나 알아둬야 할 것은 뭐든지 프로토콜을 어떻게든 해석한 결과라는 것이다. 예를 들어, RESTful API는 요청 결과를 JSON(JavaScript Object Notation) 포맷으로 돌려주는 경우가 많다. 라이브러리를 이용할 때는 HTTP 응답의 Body를 JSON으로 파싱 하여 Dictionary 객체를 얻어 사용하곤 한다. 이미 HTTP 응답의 Body가 JSON 포맷으로 쓰여있다는 것을 알고 있어야 이 방법이 통한다. 실제 HTTP 응답의 Body는 바이트 덩어리일 뿐이고, 바이트 덩어리만 봐서는 무슨 내용인지 알 수 없다. 만약 서버가 XML(eXensible Markup Language) 포맷의 데이터를 줬는데 JSON으로 파싱 하려 한다면 에러가 날 것이다. 이를 명확하게 알려주기 위해 HTTP Header에 Content-Type 필드가 있는 것이다.

응용 계층에서의 프로그래밍을 위해서는 소켓 프로그래밍을 알아보면 된다. 그 자체로도 공부해야 할 것이 많을뿐더러 단순히 소켓 프로그래밍뿐만 아니라 다중 소켓 처리를 위한 방법 등 다양한 분야와 연관되므로 이 글에서 다루기 어렵다. 무엇보다 강조하고 싶은 것은 데이터를 다루는 규칙인 프로토콜을 정의하는 것과 그 프로토콜에 따라 작동하도록 프로그램을 작성하는 것이 네트워크 프로그래밍의 기본이라는 것이다.



이로써 컴퓨터 네트워크에 대한 여러 주제를 살펴보았다. 워낙 방대한 분야인 데다 생소한 용어도 많다 보니 정리하기가 쉽지 않았던 것 같다. 다음 편에서는 데이터베이스에 대해 다뤄보려고 한다.

운영 체제 또는 오퍼레이팅 시스템(Operating System, OS)은 시스템 하드웨어를 관리할 뿐 아니라 응용 소프트웨어를 실행하기 위하여 하드웨어 추상화 플랫폼과 공통 시스템 서비스를 제공하는 시스템 소프트웨어이다. 최근에는 가상화 기술의 발전에 힘입어 실제 하드웨어가 아닌 하이퍼바이저 위에서 실행되기도 한다. 학교 강의에서 교수님께서는 운영 체제는 리소스 관리자라고 많이 말씀하셨던 기억이 난다. CPU, 메모리, 보조 기억 장치, 네트워크 등의 자원들을 잘 관리하여 응용 소프트웨어들에게 제공해주는 역할을 하는 소프트웨어인 것이다. 

하드웨어 추상화 플랫폼 또한 중요한 개념이다. 추상화는 프로그래밍에 있어 매우 중요하고 유용한 개념이다. 응용 소프트웨어를 개발할 때 파일 접근이 필요하면 보조 기억 장치가 어떤 장치인지 몰라도 이용할 수 있고, 물리 메모리가 부족하여 스왑핑이 일어나야 하더라도 직접 보조 기억 장치에 페이지 내용을 복사하는 작업 등을 할 필요가 없다. 단지 OS에게 요청하기만 하면 알아서 잘 처리된다.



개요

아래 사진은 OS가 제공하는 몇 가지 주요 서비스에 대해 그림으로 나타낸 것이다. 사용자가 운영 체제와 맞닿아 있는 부분을 사용자 인터페이스(User interface)라고 하며 GUI 또는 CUI로 나뉜다. 실제 서비스를 받기 위한 방법으로 시스템 콜(System calls)들이 제공된다. 윈도우에서는 WinAPI라는 이름으로 불린다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/2_Structures.html


학부 운영 체제 강의는 크게 프로세스 관리, 메모리 관리, 스토리지 관리, 보호와 보안 네 부분을 배운다. 네트워크도 중요하지만 다른 강의에서 따로 다루기 때문에 주요 부분에서 제외되어 있다. 보통 리눅스를 바탕으로 설명하는데, 각 부분이 리눅스에서 어떻게 운영되고 있는지 설명하는 식이다. 프로세스 관리에서는 현대 운영 체제의 기본적인 기능인 멀티프로그래밍(multiprogramming)을 지원하기 위한 방법들을 다루고, 메모리 관리에서는 거의 대부분이 가상 메모리에 대한 내용이다. 스토리지 관리에서는 대용량 스토리지들의 유형과 파일 시스템에 대해 다루고 보호와 보안에서는 보호의 방식과 암호화 등의 내용을 다룬다. 운영 체제가 하는 일이 많기 때문에 한 학기 동안의 강의에서 모든 내용을 깊게 다루기는 어려우므로 여러 분야를 얉게 알아보는 편이다.



프로세스 생애주기

프로세스는 프로그램의 인스턴스로 운영 체제에서 가장 기본적인 실행 단위이다. 각 프로세스는 메모리를 차지하고, 일정 상태 주기를 가진다. 아래 사진은 프로세스의 메모리 구조를 간략하게 나타낸 것이다. 어떻게 프로그램을 실행되게 만들었느냐에 따라 다르지만 보통 지역 변수나 함수 호출로 인한  같은 것은 스택 영역에서 메모리가 할당되고, 동적 메모리 할당은 힙 영역에서 메모리가 할당된다. 

출처 : https://commons.wikimedia.org/wiki/File:Process-in-memory.jpg

프로세스는 보통 5가지 상태 중 하나의 상태로 존재한다. 처음 프로그램을 실행하기 위해 OS에게 요청하면 프로세스를 new 상태로 생성하고 실행 가능한 상태가 되면 ready 상태가 된다. ready 상태의 프로세스는 스케쥴러에 의해 running 상태가 되어 실행되다가 I/O 혹은 이벤트 대기에 의해 waiting 상태가 되거나 인터럽트에 의해 ready 상태가 된다. waiting 상태였던 프로세스는 I/O나 이벤트가 완료됨에 따라 다시 ready 상태가 된다. 실행 중이던 프로그램이 종료되면 terminated 상태가 되고, 곧 OS에 의해 자원이 회수된다. 

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/3_Processes.html

위의 프로세스 메모리 구조는 프로세스 자체의 인스턴스이고, 운영 체제에서는 각 프로세스를 관리하기 위한 PCB(Process Control Block)이라는 별도의 자료 구조로 프로세스들을 관리한다. PCB에는 프로세스가 실행되는 동안 필요한 정보인 Program Counter나 레지스터 값들, 가상 메모리를 위한 페이지 테이블, 열었던 파일 목록 등이 포함된다. 아래 사진은 프로세스 실행 도중 다른 프로세스가 실행되는 흐름을 나타내는 다이어그램이다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/3_Processes.html



스케쥴링(Scheduling)

앞서 현대 OS의 기본적인 기능으로 멀티프로그래밍이 있다고 하였다. 싱글 코어 프로세서일 경우 동시에 여러 프로그램을 실행할 수 없기 때문에 실제로는 시분할을 통해 각 시간 분할마다 다른 프로그램을 실행하는 것으로 동시에 여러 프로그램을 실행하고 있는 것 같은 효과를 준다. 이때 어떤 프로그램이 프로세서에 의해 실행되게 할지 결정해주는 역할을 하는 것이 스케쥴러이다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/3_Processes.html

스케쥴러는 크게 장기 스케쥴러(Long-term scheduler), 단기 스케쥴러(Short-term scheduler)로 나뉜다. 장기 스케쥴러는 새로 프로세스를 실행하는 것과 같이 덜 주기적이어도 되는 작업에 적용되며, 훨씬 복잡한 알고리즘으로 만들어질 수 있다. 단기 스케쥴러는 실행 중이던 프로세스를 멈추고 다른 프로세스를 실행하려는 것과 같이 매우 짧은 주기로 실행되는 스케쥴러이다. 매우 자주 실행되기 때문에 단순한 알고리즘이어야 한다. 어떤 프로그램은 I/O 사용이 많고, 어떤 프로그램은 CPU 사용이 많은 프로그램이기 때문에 둘을 적절히 섞어서 실행해야 CPU를 최대한 활용할 수 있다.

어떤 프로세스가 실행되다가 다른 프로세스가 실행되어야 할 때 문맥 전환(Context switching)이 일어난다. 문맥 전환을 하고 나면 완전히 다른 프로세스가 실행 가능해야 한다. 어떻게 이것이 가능할까 싶지만 Program Counter를 포함한 레지스터 값들과 파이프라이닝 장치들, 컨트롤 장치의 값 등이 복구되면 완전히 다른 프로세스를 실행하는 게 된다. 그러나 값들을 다시 복사해야 한다는 점에서 적지 않은 오버헤드다. 문맥 전환은 매우 매우 자주 일어나기 때문에 빠르면 빠를수록 좋다.

스케쥴링 방법은 선점형(preemtive)과 비선점형(non-preemtive)으로 나뉜다. I/O 작업이나 wait 시스템 콜로 waiting 상태가 되는 경우 비선점형 스케쥴링 방식이라고 하며, 프로세스가 실행 중에 timeout 등으로 인해 waiting 상태로 변경될 수 있다면 선점형 스케쥴링이라고 한다. Dispatcher는 ready 상태의 프로세스 중 하나를 dispatch 하여 running 상태로 만든다. 

스케쥴링 방법은 여러 가지 존재할 수 있으며, 각 방법을 평가할 수 있기 위해서 평가 척도가 필요하다. 여러 가지 평가 척도가 있겠지만 CPU 사용량(CPU utilization), 단위 시간당 완료하는 프로세스량(Throughput), 소요 시간(Turnaround time), 대기 시간(Waiting time), 응답 시간(Response time) 정도를 포함한다. 각 스케쥴링 알고리즘에 대한 자세한 설명은 각 링크를 참고.


비선점 프로세스 스케줄링

FCFS 스케줄링(First Come First Served Scheduling)

SJF 스케줄링(Shortest Job First Scheduling)

HRRN 스케줄링(Highest Response Ratio Next Scheduling)


선점 프로세스 스케줄링

RR 스케줄링(Round Robin Scheduling)

SRTF 스케줄링(Shortest Remaining-Time First Scheduling)

다단계 큐 스케줄링(Multilevel Queue Scheduling)

다단계 피드백 큐 스케줄링(Multilevel Feedback Queue Scheduling)

RM 스케줄링(Rate Monotonic Scheduling)

EDF 스케줄링(Earliest Deadline First Scheduling)



프로세스 연산들

주요 프로세스 연산은 역시 프로세스 생성과 종료이다. 리눅스에서 프로세스를 생성하는 방법은 보통 fork류 시스템 콜을 호출하여 프로세스를 복사한 뒤, 자식 프로세스로서의 실행을 하거나 exec류 시스템 콜을 호출하여 다른 프로그램으로 바뀌어 실행하는 방법이다. 다른 방법들은 세부적인 사항에서 몇 가지 차이가 있겠지만 큰 흐름상의 차이는 없다. fork 시스템 콜 호출 뒤 자식 프로세스는 fork 시스템 콜 다음부터 실행된다. 만약 부모 프로세스가 자식 프로세스의 종료를 기다리고 싶다면 wait 시스템 콜을 호출하면 된다. main 함수가 종료되기 전에 임의로 프로세스를 종료하고 싶다면 exit 시스템 콜을 호출한다. exit 시스템 콜은 정수 인자를 받는다. 이는 main 함수에서 반환하는 값과 같은 의미인데 프로세스의 종료 결과를 알려주는 방법이다. 예를 들어, 프로세스가 0을 반환하고 종료하지 않은 경우 에러가 발생했다는 의미가 약속되어 있다.



프로세스 간 통신

종종 여러 프로세스가 협업할 필요가 있는 경우가 있다. 예를 들어 브라우저는 렌더러(Renderer) 프로그램과 각 플러그 인 별 프로세스를 가지는 프로세스다. 렌더러 프로세스는 HTML/CSS를 읽어 렌더링하고 인터프리터로 JS 코드를 실행해야 한다. 각 역할별 프로그램을 분리하여 모듈화 한 것이다. 이런 실행 환경에서는 프로세스 간의 통신이 가능해야 한다.

프로세스 간 통신(Interprocess Communication)은 운영 체제의 도움이 필요하다. 프로세스는 보안 등의 문제로 각자 독립된 메모리 공간을 가지기 때문에 단순 메모리 값 변경은 다른 프로세스에게 데이터를 전달하는 방법으로 사용될 수 없다. 

첫 번째로 공유 메모리(Shared-Memory Systems)를 사용하는 방법이 있다. OS에게 어떤 영역의 메모리를 다른 프로세스와 공유하고 싶다는 요청을 하는 것이다. 두 번째로 메세지 전달(Message-Passing Systems)을 사용하는 방법이 있다. 프로세스와 프로세스 간에 메세지를 전달할 수 있는 방법을 만드는 것이다. 메세지 전달 방법은 OS에서 내부적으로 메세지 큐를 두는 방법, 파이프를 만드는 방법, 소켓을 이용하는 방법 등 여러 가지 방법으로 구현될 수 있다. 



쓰레드(Threads)

쓰레드는 어떠한 프로그램 내에서, 특히 프로세스 내에서 실행되는 흐름의 단위이다. 쓰레드마다 다른 실행 흐름을 가지기 때문에 각자 레지스터 집합과 스택 영역을 가지며 힙 영역은 공유된다. 여러 쓰레드가 동시에 실행하는 방법은 I/O 작업과 같이 시간이 오래 걸리는 작업 중에 CPU 사용이 많은 작업을 하고 싶을 때와 같은 상황에 유용하게 쓰일 수 있다. 또한, 서버 프로그램과 같이 워커(Worker)가 여럿 필요한 경우에도 유용하다. 만약 멀티 프로세서 환경에서 어떤 작업들이 서로 의존성이 없다면 동시에 실행함으로써 처리 속도를 향상할 수도 있다. 쓰레드는 실행 흐름은 독립적이지만 메모리 공간은 공유하고 있기 때문에 경량 프로세스(Light Weight Process)라고 부르기도 한다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/4_Threads.html

멀티쓰레딩을 지원하는 방법으로는 다대일, 일대일, 다대다 방법이 있다. 일반적으로 사용자가 생성하는 쓰레드는 유저 수준 쓰레드라고 분류하고 실제 시스템에서 실행되는 쓰레드를 커널 수준 쓰레드라고 하는데, 유저 수준 쓰레드와 커널 수준 쓰레드를 어떤 방식으로 맵핑하느냐의 차이이다. 다대일 모델은 여러 유저 수준 쓰레드를 하나의 커널 수준 쓰레드에 사상하고, 일대일 모델은 각 유저 수준 쓰레드마다 커널 수준 쓰레드에 사상한다. 다대다 모델은 여러 유저 수준 쓰레드를 여러 커널 수준 쓰레드에 사상한다. 한 유저 수준 쓰레드에서 blocking 연산을 하는 경우 다른 모든 유저 수준 쓰레드가 멈춘다는 점에서 다대일 모델은 통용되기 어렵다. 커널 수준 쓰레드는 생성하기에 오버헤드가 있으므로 일대일 모델보다는 현실적으로 다대다 모델이 사용된다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/4_Threads.html

Java와 같이 VM 위에서 실행되는 환경이 아니라면 쓰레드를 생성하는 방법도 고려할 점이 된다. POSIX 표준을 지키는 OS라면 표준 pthread 라이브러리를 이용하면 된다. 윈도우에서는 WinAPI에서 제공되는 쓰레드 생성 API를 이용해야 한다. 쓰레드도 프로세스와 마찬가지로 다른 쓰레드의 종료를 기다리는 함수 등을 제공한다.

쓰레드 개념이 추가되면서 고려해야 할 점들이 생긴다. 만약 fork나 exec 시스템 콜을 호출하는 경우 쓰레드는 어떻게 되어야 하는가? 프로세스에 신호(signal)가 전달되는 경우 어떤 쓰레드가 전달받도록 해야 하는가? 쓰레드 취소는 즉시 되어야 하는가? 쓰레드에 대한 스케쥴링은 어떻게 이루어져야 하는가? 이러한 점들은 시스템마다 다르게 구현된다.



동기화(Synchronization)

프로세스 내에 여러 실행 흐름이 존재할 수 있게 되면서 동기화 문제가 대두된다. 여러 쓰레드가 공유 자원을 동시에 접근하려는 경우 발생하는 경쟁 상태(Race condition) 때문이다. 이러한 경쟁 상태를 일으키는 코드 영역을 임계 구역(Critical section)이라고 한다. 예를 들어 두 쓰레드가 한 변수에 서로 다른 값을 동시에 대입하려 한다면 문제가 생기게 된다. 

경쟁 상태가 일어나지 않게 하기 위해서는 다른 쓰레드가 이미 작업 중인 경우 작업이 끝날 때까지 기다릴 수 있도록 지원해줘야 한다. 현실적으로 별도의 하드웨어 지원을 받지 않는 이상 어떤 알고리즘도 경쟁 상태로부터 자유로울 수 없다. 하드웨어 지원을 통해 어떤 변수의 값 수정을 원자적으로 가능하게 함으로써 뮤텍스 록(Mutex locks)이나 세마포어(Semaphore)와 같은 해결 방법들을 제안한다. 

그러나 록킹 방법은 데드록(Deadlock)을 일으킬 수 있다. 서로 공유 자원을 점유하고 있는 상황에서 서로의 공유 자원을 얻으려 할 때 데드록이 발생하며, 이러한 문제로 가장 유명한 것이 식사하는 철학자들 문제다. 데드록에 대한 해결 방법이 몇 가지 제시되는데, 크게 예방, 회피, 감지, 복구 방법이 있다. 자세한 내용은 다음 문서를 참고.

록을 얻는 방법은 결국 잠재적으로 데드록이 일어날 가능성을 가진다. 프로그래머가 실수를 하게 되는 경우 데드록이 발생할 수 있기 때문이다. 모니터(Monitor) 방법은 한 번에 하나의 프로세스만 모니터에서 활동하도록 보장해준다. 어떤 공유 데이터에 대해 모니터를 지정해놓으면, 프로세스는 그 데이터를 접근하기 위해 모니터에 들어가야만 한다. 즉, 모니터 내부에 들어간 프로세스에게만 공유 데이터를 접근할 수 있는 기능을 제공하는 것이다. 또한 프로세스가 모니터에 들어가고자 할 때 다른 프로세스가 모니터 내부에 있다면 입장 큐에서 기다려야 한다. Java에서 synchronized를 명시한 메서드가 모니터의 실제 사례 중 하나라고 볼 수 있다.




프로그램 로딩

모든 프로세스는 메모리에 로드하여 실행한다. 현대 OS에서 거의 모든 작업은 메모리와 연관되기 때문에 메모리 관리를 잘 하는 것이 중요하다는 것은 굳이 더 설명할 필요 없을 것이다. 만약 하나의 프로그램만이 실행되는 시스템이라면 물리 메모리 전체를 그 단일 프로그램이 모두 사용해도 되겠지만, 현실은 수많은 프로그램을 동시에 실행하는 환경이다. 또한, 어떤 프로세스가 함부로 커널 메모리 영역을 건드린다던가 하는 일은 없어야 할 것이다. 따라서 프로세스는 각자 독립적인 메모리 공간을 갖고자 한다. 그전에 프로그램의 실행 과정을 살펴보면서 메모리 관리를 어떻게 해야 할지 생각해보자.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/8_MainMemory.html

고수준 언어로 작성된 소스 코드는 컴파일러와 어셈블러에 의해 목적 프로그램으로 컴파일된다. 위 사진에서 object module이 컴파일된 단계에 해당된다. 목적 프로그램은 linkage editor라는 프로그램에 의해 여러 다른 목적 프로그램과 연결된다. 예를 들어 각 소스 파일별로 목적 프로그램이 만들어지게 되는데, 다른 소스에 있는 함수를 참조하려면 링킹(linking)이 필요한 것이다. 연결이 완료되고 나면 loader라는 프로그램에 의해서 실제로 물리 메모리에 로딩된다. 이때, 동적 로드하기로 되어 있는 시스템 라이브러리 등이 동적 링킹 된다. 최종적으로 로딩된 프로세스는 CPU에 의해서 실행된다.



주소 변환

앞서 말한 이유로 인해 프로세스에서 물리 메모리로 직접 접근 가능하도록 하지는 않는다. 가장 기본적인 주소 변환 방법은 base와 limit 레지스터를 이용한 하드웨어를 사용하는 방법이다. 어떤 주소에 대해 base보다 높고 base + limit보다는 낮은 주소를 참조해야만 하도록 하는 하드웨어다. 만약 base보다 낮은 주소나 base + limit보다 높은 주소를 참조하려고 하면 trap을 작동시켜 에러가 나도록 한다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/8_MainMemory.html



스왑핑(Swapping)

만약 물리 메모리에 공간이 부족하다면 어떡할까? 실행 중이던 프로그램이 손상되어선 안 되므로 OS에서는 스왑핑이라는 기법으로 실행 중이던 프로그램의 메모리를 보조 기억 장치에 저장시키고 메모리를 비운 뒤 사용하는 방법을 이용한다. 보조 기억 장치는 매우 느리기 때문에 스왑핑이 일어나는 것은 성능에 큰 영향을 끼친다. 예를 들어 프로세스가 전체 물리 메모리를 다 사용하도록 하는 시스템이라면 다른 프로세스가 실행될 때마다 물리 메모리 전체를 보조 기억 장치에 복사하는 작업을 수행해야 할 것이다. 하드 디스크가 20MB/s 정도의 쓰기 속도를 보이므로 4GB 메모리를 복사한다고 하면 약 200초가 걸린다. 이건 당연히 말이 안 된다.

그러나 물리 메모리가 언제나 여유 있는 것은 아니므로 물리 메모리의 내용을 보조 기억 장치에 저장시키는 스왑핑 기법 자체는 매우 유용하다. 그렇다면 스왑핑이 물리 메모리 전체에 대해서가 아닌 일부에 대해서만 일어나게끔 개량하는 방법을 생각할 수 있을 것이다.



연속적인 메모리 할당

메모리 할당 방법에 대해 생각해보자. 보통 메모리를 딱 한 워드만 요구하지는 않기 때문에 연속적인 공간을 할당해줄 수 있어야 한다. 물리 메모리에 여유 공간이 있다고 할 때 어떤 공간을 할당해줘야 할까? 크게 First fit, Best fit, Worst fit 세 가지 방법이 있다. First fit은 여유 공간 중 첫 번째로 충분한 공간을 선택해서 할당하는 방법, Best fit은 딱 맞는 여유 공간을 선택해서 할당하는 방법, Worst fit은 가장 여유 있는 공간을 선택해서 할당하는 방법이다. 시뮬레이션에 의하면 First fit이나 Best fit 방법이 시간과 스토리지 사용량에 대해서 나은 결과를 보였다고 한다. First fit과 Best fit 둘 다 스토리지 사용량은 비슷하나 First fit이 할당할 공간을 더 빠른 시간 내에 찾았다. 따라서 First fit 방법을 이용하는 게 낫다고 볼 수 있을 것이다.

할당 방법에 따라 파편화가 생긴다. 어떤 공간에 메모리를 할당하게 되면 할당한 공간을 제외한 부분이 남게 된다. 이것을 파편화라고 하는데, 항상 딱 맞게 할당하는 것이 아니기 때문에 곳곳에 파편화된 메모리 공간들이 존재하게 된다. 언젠간 파편화가 너무 심해져서 더 이상 할당을 할 수 없게 되기 때문에 압축(compaction) 작업은 불가피하다.



세그먼테이션(Segmentation)

어차피 사용자들은 프로그램이 메모리에 연속적으로 존재한다고 생각하지 않는다. 오히려 각 함수나 변수마다 하나의 메모리 조각(segment)으로 생각하는 경향이 있다. 예를 들어 C 컴파일러는 유저 코드, 라이브러리 코드, 전역 변수, 스택, 힙을 각각의 조각으로 분리할 수도 있다. 이러한 관점과 앞서 기본적인 주소 변환 하드웨어를 결합한 것이 세그먼테이션 방법이다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/8_MainMemory.html

세그먼테이션 방법에서는 각 세그먼트마다 limit과 base가 존재하여 메모리 곳곳에 메모리 조각을 둘 수 있다. 그러나 세그먼테이션 방법에서도 각 메모리 조각은 연속된 공간에 할당되어야 하며, 그에 따라 파편화 문제는 해결되지 않는다.



페이징(Paging)

페이징 방법은 연속적인 할당조차 필요 없게 만들어준다. 페이지 테이블을 구성하고 메모리를 페이지라는 단위로 나누어 논리 주소를 물리 주소로 변환한다. 메모리 주소의 앞쪽 bit들은 페이지 번호로, 뒤쪽 bit들은 페이지 내의 offset으로 사용된다. 

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/8_MainMemory.html

페이지를 찾는 작업은 메모리에 접근할 때마다 발생하므로 매우 빠르게 수행되어야 한다. 이를 위해 하드웨어 지원을 받는데, TLB 캐시를 둠으로써 주소 변환을 빠르게 한다. 만약 TLB에 접근하는데 20 나노초가 걸리고 메인 메모리에 접근하는데 100 나노초가 걸리며 TLB 히트율이 80%라고 하면 데이터를 가져오는데 평균적으로 0.80 * 100 + 0.20 * 200 = 120 나노초가 걸린다. 이는 20% 정도 성능 저하가 된다는 것을 뜻하고, 만약 TLB 히트율이 99%가 되면 101 나노초만이 걸리는 것으로 오버헤드가 매우 적음을 알 수 있다.

페이지 테이블에는 유효 bit를 둔다. 프로세스가 모든 메모리를 항상 참조하는 것은 아니기 때문에 어떤 페이지는 물리 메모리에서 내려둘 수도 있다. 이때는 원래 페이지가 다른 프로세스에 의해 점유될 것이므로 그 메모리 보호를 위해서는 페이지 테이블의 유효 bit만을 고쳐주면 된다. 만약 접근하려는 페이지의 유효 bit가 유효하지 않다고 설정되어 있다면 해당 페이지만을 다시 스왑핑하면 될 것이다. 이는 요구 페이징과도 연관된다.

또한, 페이지를 공유할 수 있다. 시스템 라이브러리와 같이 거의 대부분의 프로세스들이 공유하게 될 메모리는 애초에 공유 페이지로 설정되어 굳이 프로세스마다 중복되게 페이지를 로드하지 말고 이미 로드되어 있는 메모리를 참조하게 하는 것으로 메모리를 절약한다.



페이지 테이블 구조

현대 OS는 최소 주소를 32bit로 표현한다. 페이지 크기는 보통 4KB 정도로 설정하는데, 그러면 페이지 테이블은 2의 20승만큼의 항목을 갖게 되고, 항목당 4byte만을 가진다고 해도 페이지 테이블의 크기는 4MB가 된다. 이는 또다시 1024개의 페이지로 구성되어야 함을 뜻한다. 프로세스가 전환될 때마다 페이지 테이블도 전환되어야 하는데 한 번에 이렇게 큰 용량을 전환하는 것은 비용이 너무 크다.

페이지 테이블을 두 단계로 나누는 방법이 있다. 첫 10bit를 외부 페이지 주소로, 다음 10bit를 내부 페이지 테이블 주소로 설정하여 일부 내부 페이지 테이블만을 로드하는 방법이다. 64bit 운영 체제는 52bit를 페이지 번호로 지정하는데, 이 경우 두 단계로 페이지 테이블을 나눈 것도 너무 크기 때문에 세 단계로 나누기도 한다.

다른 접근 방법으로는 페이지 번호를 해싱하여 해시값을 페이지 주소로 사용하는 방법이 있다. 또는, 역 페이지 테이블 접근 방법도 있다. 논리 주소에 프로세스 id를 사용하고, 페이지 테이블에서 pid를 이용하여 페이지를 찾게 한 다음 그 페이지의 테이블상 위치 값을 이용하여 주소를 변환하는 방법이다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/8_MainMemory.html



수정 시 복사(Copy on Write)

잠깐 다시 프로세스를 생각해보자. 새로운 프로세스를 실행하는 방법은 보통 fork 시스템 콜을 사용한다고 앞서 설명하였다. 그런데 페이징 방법을 고려하면 프로세스를 복사하는 것이 만만찮은 작업임을 알 수 있다. 심지어 어떤 프로세스는 복사되자마자 exec 시스템 콜을 호출하여 다른 프로세스로 바뀌어버린다. 이는 페이지나 페이지 테이블을 복사하는 것이 낭비가 될 수 있음을 뜻한다.

해법은 페이지에 수정을 가하는 경우에만 실제로 복사 작업을 하는 것이다. 어차피 수정 사항이 없는 페이지는 원본 페이지를 참조하여도 문제가 발생하지 않기 때문에 효과적이라고 할 수 있다. 단, 시스템 콜에 여러 옵션을 줘서 이를 사용자가 조절할 수 있도록 한다.



페이지 교체(Page Replacement)

원하는 페이지가 물리 메모리에 존재하지 않으면 어쩔 수 없이 어떤 페이지는 내려놔야 한다. 원하는 페이지가 물리 메모리에 없으면 페이지 폴트(Page fault)가 일어났다고 하고, 물리 메모리에서 내려갈 페이지를 Victim이라고 한다. Victim을 선택하는 알고리즘은 여러 가지가 있다.

FIFO 알고리즘은 먼저 참조되었던 페이지를 victim으로 결정하는 알고리즘이다. 단순하고 쉬우나 언제나 최적의 결과를 보여주지는 않는다. 심지어 Belady's anomaly라는, 더 많은 프레임을 사용할 수 있는 상황일 때 오히려 페이지 폴트를 더 일으키는 이상 현상을 보이기도 하는 문제가 있다. 그러나 하드웨어의 지원이 하나도 없는 시스템이라면 FIFO 알고리즘만이 남은 선택지다.

OPR(Optimal Page-Replacement) 알고리즘은 실제로 페이지 폴트를 가장 적게 일으키는 방식으로 victim을 결정하는 알고리즘이다. 당연히 현실적으로는 구현 불가능한 알고리즘이다. 대신, OPR 알고리즘을 기준으로 성능을 비교할 수 있기 때문에 유의미하다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/9_VirtualMemory.html

LRU(Least Recently Used) 가장 과거에 참조했던 페이지를 victim으로 결정하는 알고리즘이다. 오랫동안 참조하지 않았으면 앞으로도 참조하지 않을 가능성이 높다는 차원에서 그럴듯하다. 문제는 LRU를 구현하는 방법이다. Counter 방법이나 Stack 방법으로 가능한데, Counter 방법은 페이지에 접근할 때마다 counter를 올리고, victim을 찾을 때 counter가 가장 작은 것을 찾는 방법이다. Stack 방법은 페이지에 접근할 때마다 스택에 페이지 번호를 push 한다. 만약 스택에 이미 그 페이지 번호가 있었다면 꺼내어 다시 top에 푸시한다. 결국 스택의 바닥에 가장 과거에 참조했던 페이지 번호가 위치하게 될 것이다. 문제는 이러한 작업들이 메모리 접근을 할 때마다 이뤄져야 한다는 것이다. 만약 소프트웨어 수준에서 이 방법을 지원하기 위해서는 성능 저하가 너무 심해질 것이다. 따라서 하드웨어의 지원 없이는 불가능하다.

유사 LRU(LRU-Approximation) 알고리즘은 LRU 방식을 일부 적용하는 알고리즘이다. 기본적으로 참조 bit를 따로 둬서 덜 참조된 페이지를 victim으로 결정하는 방식이다. 

Additional-Reference-Bits Algorithm : 참조 bit를 더 많이 두고 bit들을 매번 오른쪽으로 shift하면서 페이지에 접근할 때마다 가장 왼쪽의 bit를 1로 변경한다. 참조 bit들을 정수로 읽었을 때 값이 가장 작은 것이 LRU에 가깝게 될 것이다. 

Second Chance Algorithm : 순환 큐에 페이지 항목들과 참조 bit를 둔다. 참조 bit가 설정되지 않은 페이지는 바로 victim으로 결정되고, 참조 bit가 설정된 페이지는 참조 bit를 초기화한다. 

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/9_VirtualMemory.html

Enhanced Second-Chance Algorithm : 참조 bit 뿐만 아니라 수정(Dirty) bit를 둔다. 수정된 페이지는 보조 기억 장치에 쓰기 작업이 필요하므로 선택 우선순위에서 떨어진다. 

참조 횟수가 가장 적은 페이지를 victim으로 결정하는 LFU 알고리즘(Least Frequently Used)과 참조 횟수가 가장 많은 페이지를 victim으로 결정하는 MFU 알고리즘(Most Frequently Used)도 있다. MFU 알고리즘은 가장 작은 참조 횟수를 가진 페이지가 가장 최근에 참조됐기 때문에 가장 적게 참조됐고, 앞으로 사용될 것이라는 판단에 근거한 알고리즘이다. 그러나 이 알고리즘은 빠르게 수행되게끔 구현이 어려울뿐더러 OPT 알고리즘에 가까운 성능을 보이지도 못하기 때문에 잘 사용되지 않는다.

페이지 버퍼링(Page-Buffering) 알고리즘은 풀링(Pooling) 방식을 적용한 알고리즘이다. 사용 가능한 물리 메모리 프레임을 풀에 보관하다가 필요하면 꺼내 쓰고 victim은 보조 기억 장치에 복사한 뒤 다시 풀에 추가한다.



쓰레싱(Thrashing)

동시에 더 많은 프로세스를 실행한다고 해서 CPU 사용량이 계속 증가하기만 하는 것은 아니다. 아무리 좋은 페이지 교체 알고리즘을 사용하더라도 프로세스들이 메모리를 많이 쓰면 페이지 폴트가 일어나 스왑핑을 할 수밖에 없고, 스왑핑을 위한 IO 작업이 많이 발생하면 CPU 사용량이 떨어진다. 이때, CPU 사용량이 급감하기 시작하는 것을 쓰레싱이라고 한다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/9_VirtualMemory.html



커널 메모리 할당

프로세스가 유저 모드에서 실행되면서 추가적인 메모리를 요청하면 커널이 비어있는 페이지 중 하나를 할당해준다. 페이지 하나를 덜컥 할당해주는 것이기 때문에 내부 파편화 문제는 어쩔 수 없이 발생한다. 이는 메모리 할당을 빠르게 해주기 위해 어쩔 수 없다. 메모리 할당은 매우 빈번하게 일어나고 매번 복잡한 알고리즘으로 수행한다면 성능이 떨어질 것이다. 커널 메모리는 유저 모드 프로세스의 메모리 할당과 다르게 메모리 풀을 이용해서 메모리를 할당한다. 커널 메모리를 다른 방식으로 할당하는 이유는 아래 두 가지가 중요하다.

커널에서 사용하는 자료 구조들은 크기가 다양하고 페이지보다 용량이 적다. 조심스럽게 메모리 할당을 하지 않으면 파편화로 인해 메모리를 많이 낭비하게 된다. 많은 OS가 커널 코드나 데이터에는 페이징을 적용하지 않고 있다.

유저 모드 프로세스에게 할당된 메모리는 물리적으로 연속적이지 않아도 큰 상관이 없다. 그러나 커널 메모리 공간은 하드웨어 장치로부터 직접적으로 물리 메모리에 접근되기 때문에 물리적으로 연속적일 필요가 있다.

이러한 이유 때문에 커널 메모리는 다른 전략으로 관리된다. 그중 Buddy System과 Slab Allocation 방법을 알아보자. Buddy System은 물리적으로 연속된 메모리 공간을 2의 지수로 쪼개어 사용하는 방법이다. 커널에서 요구하는 메모리 크기보다 같거나 큰 덩어리를 할당한다. 예를 들어 21KB를 요청받으면 32KB 덩어리를 할당한다. 형제 노드를 buddy라고 하는데, 형제 노드가 모두 메모리 해제되면 둘은 합병(Coalescing)된다. 이로써 나중에 더 큰 물리적으로 연속된 메모리를 할당할 수 있게 된다. Buddy System 전략으로 메모리를 할당하면 외부 단편화를 해결할 수 있다.

출처 : Operating System Concepts 9th edition

Slab Allocation 방법은 슬랩 단위로 메모리를 관리하면서 내부 단편화를 해결하면서 초기화 속도 등을 향상하는 방법이다. 기본적인 아이디어는 메모리를 커널에서 사용하고 있는 자료 구조들의 객체로 취급하는 것이다. Slab cache 체인은 각각 slab 목록을 포함하는데, 일반적으로 slab 목록은 물리적으로 연속된 메모리이다. slab은 완전히 할당이 완료되었거나(slabs_full), 일부만 할당되었거나(slabs_partial), 비어있는(slabs_empty) 상태 세 가지로 나뉜다. 커널의 자료 구조에 대한 메모리 할당을 요청받으면 Slab allocator는 미리 메모리를 할당해둔 slab 속의 메모리를 준다. 미리 할당해둔 메모리를 주기 때문에 물리 메모리 상의 빈 공간을 찾기 위한 시간이 들지 않고, 메모리를 반환할 때도 Slab allocator에게 반환하여 후에 재활용하기 때문에 실제로 메모리 할당을 위한 비용이 줄어들게 된다. 

출처 : Operating System Concepts 9th edition



대용량 저장 장치(Mass-Storage)

메모리를 넘어 스토리지 관리에 대해 알아본다. 메모리는 크기가 작고 휘발성이기 때문에 비휘발성 저장 장치가 필요하다. 가장 익숙한 것은 HDD(Hard Disk Drive)일 것이다. 이제는 SSD(Solid State Disk)가 많이 대중화되었지만 과거엔 HDD가 시장을 지배하고 있었다. HDD는 물리적으로 회전하고 이동하는 장치에 의해 작동되는 방식이기 때문에 어떤 데이터를 접근할지 잘 스케쥴링하는 것이 성능에 중요한 영향을 끼쳤다. 암이 이동하고 디스크가 회전하는 시간이 밀리초 단위이기 때문에 먼 곳으로 이동하기 위해선 긴 시간이 걸리기 때문이다. 디스크 스케쥴링에 대한 자세한 항목은 다음 문서를 참고.

보통 저장 장치는 SATA 등의 방식으로 물리적으로 연결되어 있지만, 네트워크상으로 연결될 수도 있다. 지금도 많이 쓰고 있는 NAS(Network-Attached Storage)SAN(Storage-Area Network)과 같은 것이 네트워크상으로 저장 장치를 연결하는 방법이다. 

OS와 같은 프로그램은 휘발되면 안 되기 때문에 저장 장치에 저장되어 있다. 컴퓨터를 처음 켜면 ROM에 있는 부트스트랩(bootstrap) 프로그램이 디스크에서 MBR(Master Boot Record)에 접근하여 부팅하도록 한다. 조금 다르긴 하지만 OS가 프로세스를 실행시켜주는 것과 비슷하다고 볼 수 있다.

은행과 같이 저장된 데이터가 매우 매우 중요한 곳은 저장 자체를 여분으로 더 많이 해둔다. RAID(Redundant Array of Inexpensive Disks)는 널리 사용되는 중복 저장 및 유효성 검사 방식이다. RAID 방식도 여러 가지가 있기 때문에 자세한 내용은 링크된 문서를 확인. 데이터베이스 서버는 데이터가 손상되지 않게 잘 저장해야 하는 시스템이기 때문에 RAID로 구성하는 것이 매우 중요하다.



파일 시스템(File System)

파일 시스템은 파일이라는 단위로 대용량 저장 장치를 잘 관리해주는 시스템이다. 앞서 언급한 바와 같이 저장 장치는 물리적으로 연결되어 있을 수도, 네트워크로 연결되어 있을 수도 있다. 이를 추상화하여 파일을 다루는 서비스를 제공하는 것이 파일 시스템이다. 가장 널리 알려진 파일 시스템으로는 FATNTFS 등이 있다.

OS마다 차이가 있겠지만 기본적으로 파일은 이름과 식별자, 종류, 위치, 크기 등을 포함한다. 파일에 대한 연산에는 파일 생성, 파일 쓰기, 파일 읽기, 파일 내 위치 이동, 파일 삭제, 파일 내용 초기화가 있다. 거의 대부분의 OS는 파일에 대한 연산을 수행하기 전에 그 파일을 여는 작업을 먼저 하도록 한다. 그러한 연산이 일어나는 곳은 프로세스이므로 프로세스는 열린 파일 테이블(Open file table)이라는 것을 가진다. 열린 파일에 대한 연산은 File pointer라는 것으로 다뤄진다. 파일은 여러 프로세스에 의해 동시에 접근될 수도 있기 때문에 어떤 시스템은 동기화를 위해 파일 락킹(File locking) 기능을 제공한다. 예를 들어 함께 파일을 열어 읽을 수 있는 락킹 모드(shared lock)라던가, 남이 파일을 열 수 없게 하는 락킹 모드(exclusive lock) 등을 제공한다.

널리 알려진 파일 종류는 크게 다음과 같다. 엄밀히 말해서 리눅스에서 파일 종류는 크게 디렉터리 파일과 일반 파일, 디바이스 파일로 나뉜다. 아래 파일 종류는 어떠한 규칙으로 저장된 일반 파일에 대해 프로그램이 각자 해석하는 것이지 시스템상으로 차이가 있진 않다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/11_FileSystemInterface.html

저장 장치 상에서 접근 방법이 여러 가지 존재할 수 있다. 순차 접근(Sequential Access)은 시작점부터 종료점까지 위치를 순차적으로 이동하는 방법이다. 오직 되감기를 하거나 다음으로 이동만 가능하다. 자기 테이프와 같은 저장 장치에서는 이 방식이 유효하다. 직접 접근(Direct Access)은 저장 장치상의 특정 위치로 직접 이동할 수 있다. 저장 장치의 작동 방식에 따라 직접 접근도 접근에 시간이 걸릴 것이다. 어떤 파일이 어디에 위치해있는지 확인할 수 있도록 인덱스를 구성하기도 하고, 파일이 아주 많은 경우에는 인덱스의 인덱스를 구성하기도 한다. 인덱스를 잘 활용하는 대표적 프로그램으로 데이터베이스 관리 시스템(DBMS)이 있다.

디렉터리 구조도 여러 가지 방법으로 결정할 수 있다. 하나의 루트 디렉터리에 모든 파일이 모여있는 단일 수준 디렉터리(Single-Level Directory), 루트 디렉터리 아래에 하나의 디렉터리가 더 존재할 수 있는 이 단계 디렉터리(Two-Level Directory), 트리와 같은 구조로 디렉터리가 존재할 수 있는 트리 구조 디렉터리(Tree-Structured Directories), 비순환 그래프 구조의 디렉터리(Acyclic-Graph Directories), 아예 순환도 존재할 수 있는 일반 그래프 구조 디렉터리(General Graph Directory)가 있다. 디렉터리 구조에 따라 디렉터리 순회 방법에 대한 고려가 필요하다.

파일 시스템은 마운팅(Mounting) 될 수도 있다. 어떤 디렉터리를 마운트 포인트(mount point)로 보고 마운트 포인트에 다른 파일 시스템을 장착하는 식이다. 네트워크로 연결된 저장 장치도 마운트 한 뒤 파일 접근을 통해 사용할 수 있으므로 쉽고 강력한 기능이라고 볼 수 있다. 여러 저장 장치를 한 시스템에 연결해서 사용한다면 각 저장 장치를 볼륨이라는 이름으로 이미 마운트 해서 쓰고 있는 것이기도 하다.

현대 OS에서는 여러 유저가 시스템을 사용할 수 있도록 다중 사용자 기능을 제공한다. 이 경우 유저 간의 파일 공유가 문제가 될 수 있다. 또는, 네트워크 상으로 연결됐을 때 파일을 공유해줄 수 있도록 하는가도 고려 사항이 된다. 다른 사용자로부터 내 파일을 접근하지 못하도록 하고 싶을 수 있다. 따라서 파일에 대한 보호 기능이 제공된다. 파일 연산에 대해 권한을 나누어 권한별 사용이 가능하게 하는 것이 Access Control이다. 보통 읽기, 쓰기, 실행 권한 정도로 나눈다. 이에 대해서는 추후 보호 부분에서 더 다룬다.



파일 시스템 구현

파일 시스템은 볼륨마다 boot block, master file table(superblock)을 두고 파일마다 FCB(File Control Block)을 둔다. FCB는 파일에 대한 자세한 정보가 있는 테이블이다. UNIX에서는 inode에 FCB를 저장한다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/12_FileSystemImplementation.html

시스템에서는 메모리에 마운트 테이블, 최근 접근한 디렉터리 정보 캐시, 시스템 차원의 열린 파일 테이블과 프로세스당 열린 파일 테이블을 관리한다. 아래 사진은 열린 파일 테이블이 어떻게 사용되는지에 대한 그림이다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/12_FileSystemImplementation.html

리눅스에서는 VFS(Virtual File System)이라는 추상화 계층을 둔다. 파일 시스템 인터페이스 하에 추상 계층을 두고 추상 계층을 통해 각 파일 시스템이 이용된다. 리눅스의 VFS는 inode 객체, 파일 객체, superblock 객체, directory 객체를 기반으로 운영된다. 아래 그림은 VFS이 어떤 형태인지 보여주는 그림이다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/12_FileSystemImplementation.html



저장 장치의 공간 할당

메모리 때와 마찬가지로 저장 장치의 공간을 할당하는 방법도 고민의 대상이다. 기본적으로 저장 장치는 블록이라는 단위로 나뉜다. 파일을 블록들을 물리적으로 연속하여 위치하도록 하면 당연히 좋겠지만 현실적으로 모든 블록을 연속적으로 두는 것은 어렵다. 각 디스크 블록을 연결된 리스트로 두는 방법도 있다. 하지만 이 방법은 중간 접근을 위해 각 블록을 순차적으로 읽어야 하기 때문에 성능 저하를 일으키게 된다. 곧바로 임의 위치 블록을 찾아 이동할 수 있도록 하기 위해 인덱스를 할당하는 방법이 대안이다. 다만 인덱스를 두는 블록을 어떻게 관리하고 구현할지가 문제가 된다. Linked Scheme, Multi-Level Index, Combined Scheme 등의 접근 방식이 존재한다. 디스크 블록을 읽어 들이는 작업은 매우 느리기 때문에 무엇보다도 디스크 접근을 최소화하는 방법이 성능상의 우위를 보이게 될 것이다. 아래 사진은 UNIX의 inode 관리를 위해 사용되는 Combined Scheme 방식을 보여준다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/12_FileSystemImplementation.html

가용 공간을 어떻게 관리할지도 생각할만한 부분이다. 저장 장치를 탐색하면서 저장 공간을 관리하는 것은 너무 오래 걸리기 때문에 따로 지속적으로 저장 공간 관리를 한다. 대표적으로 bit 벡터 방식과 연결된 리스트 방식이 있다. bit 벡터는 bit당 블록 하나로 쳐서 bit가 1이면 사용 가능하고, 0이면 사용 중인 것으로 간주한다. 매우 적은 용량과 빠른 알고리즘으로 가용 공간을 관리할 수 있다. 연결된 리스트 방법은 가용 블록들을 연결된 리스트로 관리하여 할당하기에 적합한 블록을 찾는 방법이다. 그 외, 그룹핑이나 카운팅, 공간 맵 방법 등이 있다. 아래 사진은 가용 블록들을 연결된 리스트로 관리하는 예시이다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/12_FileSystemImplementation.html



성능

파일 시스템에 있어서 성능은 무조건 보조 기억 장치에 접근을 최소화하는 것이다. 파일 시스템에서 읽어온 데이터를 캐시 해두고 있다면 저장 장치에 접근하지 않아도 되기 때문에 성능상에 큰 이득을 볼 수 있게 된다. 디스크 블록을 캐시 해두는 것을 buffer cache, buffer cache를 페이지처럼 쓸 수 있게 따로 연결해두는 것을 page cache라고 한다. buffer cache와 page cache 둘이 중복으로 저장해두는 것은 메모리 낭비를 일으키므로 이를 해결하기 위한 unified buffer cache 방식으로 캐시 하기도 한다. unified buffer cache 방식에서는 memory-mapped I/O와 일반 I/O를 수행할 때 통합된 buffer cache에 접근하게 된다.

page cache를 관리하는 전략으로 Free-behind 방식과 Read-ahead 방식이 있다. 둘은 LRU에 기반한 아이디어로 Free-behind는 다음 페이지를 접근하자마자 이전의 페이지를 내려놓는 방식이다. 이미 읽은 데이터를 다시 필요로 할 가능성이 낮기 때문이다. Read-ahead 방식은 다음 페이지를 미리 읽는 방식이다. 순차적으로 데이터를 읽고 있는 상황에서 다음 페이지에 접근하게 될 가능성이 높다고 예상되기 때문이다.

디스크에 쓰기 작업을 하는 것도 동기 방식과 비동기 방식으로 나뉠 수 있다. 디스크에 쓰기 하려고 할 때 바로 쓰기 작업을 수행하는 것을 동기, 쓰기 요청을 캐시 해뒀다가 좀 더 효율적인 스케줄로 쓰기 작업을 수행하는 것을 비동기 방식이라고 한다. 시스템은 상황에 따라 적절한 방법을 선택할 수 있도록 기능을 제공한다.



I/O 시스템

I/O 장치를 관리하는 것도 OS의 중요한 부분 중 하나이다. 키보드, 마우스, 저장 장치 등의 I/O 장치들을 잘 관리해야 한다. 각 I/O 서브시스템들은 장치 드라이버라는 것을 통해 OS로부터 조종되며, I/O 하드웨어들은 port를 통해 연결되고 버스를 통해 데이터를 전송한다. 장치로부터 입출력을 하는 방법은 폴링(Polling) 방식과 인터럽트(Interrupts) 방식으로 나뉜다. 

폴링 방식은 주기적으로 장치를 확인하여 데이터를 입출력하는 방법이다. 장치를 위해 busy waiting을 하게 돼서 시간을 많이 낭비하게 된다. 반면 인터럽트 방식은 입출력이 수행된 뒤 인터럽트를 통해 작업 종료를 알려주게 되므로 주기적으로 다시 확인할 필요가 없다. 아래 방식은 인터럽트 주도 I/O 작업의 주기이다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/13_IOSystems.html

장치가 직접 메모리에 접근하게 하는 DMA(Direct Memory Access) 방식도 있다. 잠시 버스를 점유하여 메모리를 사용하는 것을 제외하면 장치가 직접 메모리에 접근하므로 효율적이다.



보호(Protection)

OS에서의 보호는 사용자나 프로그램의 고의적인 악용을 막는 것을 말한다. 보호를 통해 OS는 공유된 자원을 시스템의 정책이나 설계자, 관리자에 의해 정해진대로 쓰고 문제가 있는 프로그램으로 인한 손상을 최소화하고자 한다.

최소 권한의 원칙은 보호에 있어 중요한 원칙이다. 사용자나 프로그램은 최소한의 권한만을 부여받아 일을 수행할 수 있게 한다. 최소 권한의 원칙에 따르면 사용자나 프로그램이 다른 무슨 일을 하려고 해도 권한이 부족하기 때문에 해를 입히기 어렵게 된다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/14_Protection.html

컴퓨터는 프로세스들과 객체들의 모음으로 볼 수 있고, 도메인이란 객체에 대한 권한의 모임이다. Need to know 원칙은 어떤 일을 수행하기 위해 알아야 하는 객체만 알려주는 원칙을 말한다. Need to know 원칙에 따라 최소한의 객체만을 포함하는 도메인을 제공하고자 한다. UNIX에서는 유저를 하나의 도메인으로 볼 수 있다. 

다른 일을 수행하기 위해 다른 권한이 필요하면 도메인 전환이 필요하다. 접근 제어 행렬 방법에서는 객체와 도메인 간의 권한을 테이블로 만들어 관리한다. 아래 사진은 접근 제어 행렬 방법에서의 테이블의 예이다. 어떤 도메인이 객체에 대해 어떤 권한이 있는지, 어떤 도메인에서 다른 도메인으로 전환 가능한지 표시를 해두는 식이다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/14_Protection.html

접근 제어 행렬 방법을 구현하는 방법이 몇 가지 있다. Global Table 방법은 전체 객체, 도메인, 권한에 대한 표를 만들어두는 방법이지만, 너무 테이블이 크기 때문에 현실적이지 않다. Access Lists 방법은 객체에 대한 도메인의 목록, Capability Lists 방법은 도메인이 가진 객체 권한 목록을 나열하는 방법이다. 



접근 제어(Access Control)

역할 기반 접근 제어(Role-Based Access Control, RBAC) 방법은 사용자에게 권한이나 역할을 부여하는 방법이다. RBAC는 최소 권한의 원칙을 지원한다. 접근 권한을 파기하는 것이 Access Lists 방법에서는 쉽다. 객체의 도메인 목록에서 도메인을 제거해주기만 하면 된다. 하지만 Capability Lists 방법에서는 시스템 전체에 퍼져있는 접근 권한을 찾아 제거해야 하기 때문에 어렵다. Capability Lists 방법에서 접근 권한 파기 방법으로는 Reacquisition, Back-pointers, Indirection, Keys 방법 등이 있다.



보안(Security)

보호는 파일과 자원을 사용자 간에 보호하는 것이었다면 보안은 시스템을 내외부의 공격으로부터 막는 것을 다룬다. 물리적, 인간적(피싱이나 비밀번호 크래킹 등), 네트워크 상의 공격, OS 자체의 보안 결함으로부터 보호해야 한다. 

프로그램적인 위협으로는 트로이 목마트랩 도어, 논리 폭탄, 스택과 버퍼 오버플로우바이러스 등이 존재한다. 시스템이나 네트워크 상의 위협으로는 포트 스캔서비스 거부 공격(DOS)이 있다. 

데이터의 보안을 위해서는 암호화가 중요하다. 대칭 키 암호화 방식에서 이제는 비대칭 키 암호화 방식이 대세가 되었다. 비대칭 키 암호화 알고리즘으로 가장 유명한 것이 RSA이다. 안전한 소켓 통신을 위한 SSL(Secure Socket Layer) 통신의 키 교환과 인증에 RSA가 쓰일 수 있다.

방화벽은 네트워크 상의 공격을 막는 장치 혹은 방법이다. 외부로부터의 인터넷 접근을 제어하여 내부 컴퓨터를 보호한다. 이제는 많이 대중화되어 개인용 방화벽 소프트웨어나 OS에 기본적으로 설치되어 있기도 한다.

출처 : https://www2.cs.uic.edu/~jbell/CourseNotes/OperatingSystems/15_Security.html



이것으로 운영 체제의 주요 주제들을 살펴보았다. 넓고 얕게 많은 부분을 다루다 보니 양이 정말 많았다. 책을 정리하기엔 너무 양이 많아서 일리노이 대학교 시카고의 운영 체제 강의 노트를 참고했다. 사진 출처도 대부분 강의 노트 페이지인데, 강의 노트에서 사용된 사진도 대부분 엄밀히 말하면 Operating System Concepts 책에서 가져온 것으로 보인다.

다음 편에서는 네트워크를 다루겠다.

프로세서, 특히 CPU에서 실행되는 것은 그 CPU에 정의된 일련의 명령어 집합이다. 고수준 언어로 작성된 프로그램은 컴파일되어 목적하는 CPU 아키텍처 상의 명령어 집합으로 만들어진 프로그램을 만들도록 되어 있다. 따라서, CPU가 명령어를 어떻게 읽어 들여 실행하는지 알아보는 것이 의미가 있을 것이다. 이 글에서는 RISC(Reduced Instruction Set Computer, MIPS 등의 아키텍처에서 사용됨) 방식의 CPU 아키텍처와 폰 노이만 구조를 기반으로 설명하겠다. 워드는 32bit로 가정한다. 아래 사진은 추상적으로 나타낸 MIPS 구조이다.

출처 : 김병기 교수님 강의 노트



개요

폰 노이만 구조에서 명령어 메모리와 데이터 메모리는 나뉘어 있지 않다. 따라서 CPU는 메모리에서 PC(Program Counter)가 가리키는 주소의 워드를 읽어와서 정해진 규칙대로 명령어를 디코딩한 뒤 op 코드를 확인하여 명령어 별로 다르게 실행한다. 명령어에서 특정 주소의 워드를 레지스터에 로드하겠다거나(Load Word), 레지스터의 값을 메모리에 저장하겠다는(Store Word) 것이 있을 뿐이다. 소스 코드를 컴파일하여 만들어지는 목적 코드에는 정해진 규칙대로 코드 영역, 데이터 영역이 있고 OS에서 프로그램을 실행해 줄 때 엔트리 함수(C언어에서는 main 함수)의 시작 위치를 PC로 설정해주기만 하면 순차적으로 실행되는 것이다. (이는 간략하게 설명을 위해 세부 내용을 뺀 설명임을 밝힌다.)

대략적인 명령어 실행 순서

CPU는 항상 PC에 위치한 명령어를 실행하도록 되어 있으므로 분기 처리는 PC를 바꿔주는 명령어인 셈이다. 브랜치나 점프 명령어가 이에 해당한다. 하지만 한 워드에서 이미 op 코드를 위해 6bit를 사용했기 때문에 점프 명령일지라도 32bit 전체에 대한 주소를 설정할 수가 없다. 심지어 어떤 브랜치 명령어들은 레지스터 번호 2개를 포함하기 때문에 두 레지스터의 값이 같거나 틀리면 브랜치 하도록 하는 명령어인 beq, bne 명령어는 16bit만을 주소로 가질 수 있다. 이 때문에 절대 주소 지정, 상대 주소 지정 모드가 있다. 또한, 항상 명령어는 4byte(32bit) 단위임이 약속되어 있기 때문에 주어진 주소에 4를 곱한 값을 사용함으로써 bit 2개를 절약한다.

시간당 명령어 처리량을 더 올리기 위한 방법으로 파이프라이닝(Pipelining) 기법을 사용한다. 한 명령어 실행이 완전히 끝날 때까지 기다리지 않고 명령어 실행 단계를 나누어 단계별로 명령어를 실행하는 것으로 여러 명령어를 동시에 실행하는 방법이다. 단, 파이프라이닝을 하면서 발생하는 구조적 해저드, 데이터 해저드, 컨트롤 해저드를 해결해야 한다. 

메모리는 보조 기억 장치에 비해 훨씬 빠르게 접근 가능하지만 CPU 입장에서는 메모리를 접근하는 시간도 너무 크다. 게다가 명령어 같이 매번 접근해야 하는 데이터가 메모리에 있다는 것은 성능에 큰 저하를 일으킨다. 그렇기 때문에 CPU 내부에는 캐시(Cache)가 존재하여 메모리에 접근하는 횟수를 줄인다. 레지스터, 캐시, 메모리, 보조 기억 장치 순으로 접근 속도가 지수적으로 느려진다는 이야기를 들어봤을 것이다. 

실제로는 물리 메모리 주소로 바로 접근하지 않고 가상 메모리(Virtual Memory) 기법을 이용하여 논리 주소를 기반으로 메모리에 접근한다. 논리 주소와 물리 주소를 맵핑하는 것을 소프트웨어 수준에서 처리하는 것은 큰 성능 문제를 일으키므로, 하드웨어 지원을 통해 빠르게 처리할 수 있도록 해야 한다. 빠르게 페이지를 찾기 위해 변환 색인 버퍼(Translation Lookaside Buffer, TLB)라는 별도의 메모리를 둔다. 실제로는 MMU라는 메모리 관리 장치를 별도로 둔다고 하지만, 여기서는 간략하게 TLB 개념을 소개하는 차원에서 TLB만을 설명한다.



명령어 집합

명령어는 크게 R-type, I-type, J-type으로 나뉜다. R-type에는 보통 2개 레지스터에 대한 연산을 1개 레지스터에 저장하는 명령어들로 예를 들어 add 명령어와 같은 것으로 구성되어 있다. I-type은 두 개의 레지스터 번호와 상수를 담고 있어 어떤 메모리 주소의 값을 레지스터로 불러오거나 저장하는 lw, sw 명령어, 브랜치 명령어 들로 구성되어 있다. 마지막으로 J-type은 점프 명령어들로 더 많은 비트를 주소로 사용할 수 있다.

각 명령어들은 6bit의 op 코드로 이루어져 있다. 명령어를 가져온다는 것은 어떤 주소로부터 32bit(한 워드)를 읽어 들여 해석하는 것과 같다. 이 작업을 디코딩 작업이라고 한다. R-type을 예로 들면 워드의 가장 왼쪽 6bit를 op 코드로, 그다음 5bit를 source register로, 그다음 5bit를 target register으로, 그다음 5bit를 destination register으로, 그다음 5bit와 6bit를 shift amount, function code로 인식하는 것이다. 이것을 32bit 숫자 표현인 것으로 인식할 수도 있으나 디코딩하면서 의미를 분석함으로써 명령어로 인식할 수 있는 것이다. 아래 사진은 명령어 타입별 인코딩 규칙이다.

출처 : https://www.cise.ufl.edu/~mssz/CompOrg/CDA-lang.html

각 레지스터 지정을 위해 5bit를 쓴 것을 보면 알 수 있듯이 레지스터는 5bit로 표현 가능한 총 32개가 존재한다. MIPS 아키텍처에서 어떤 레지스터가 어떻게 쓰이는지는 MIPS Calling Convention을 참고. 그렇기 때문에 어셈블러가 어셈블리어를 목적 코드로 변환할 때 목적 코드가 실행될 CPU의 아키텍처에 정의된 명령어 집합과 인코딩 방식을 참고하여 값을 인코딩해야만 한다.



주소 지정 모드

MIPS에는 대략 5가지(강의 자료에 따라 조금씩 차이가 있다)의 주소 지정 모드가 존재한다. 크게 절대 주소와 상대 주소, 값 기반 상대 주소로 나눌 수 있을 것 같다. 위에서 명령어가 어떻게 인코딩 되어 있는지 보면 알겠지만 주소 표현을 위해 사용할 수 있는 bit가 충분히 많지 않기 때문에 주소 지정 모드가 있는 것이다. 16bit를 주소로 표현할 수 있다고 해도 262144(2의 18승)까지의 값 밖에 표현할 수 없다. 크기가 1MB 밖에 안 되는 정수 배열도 그 배열 전체를 주소로 지정할 수 없게 되는 것이다. 그 한계를 극복하기 위해 레지스터에 있는 값에 상대적인 주소를 지정하는 등의 여러 가지 주소 지정 모드를 제공한다.

각 주소 지정 모드의 자세한 작동 방식은 다음 문서를 참고. 



데이터 경로 설계(Data Path Design)

메모리부터 명령어를 가져오고, 명령어를 디코딩하고, 산술 논리 장치로 연산을 하고, 메모리로부터 값을 읽어 오거나 쓰는 작업 뒤에 다시 레지스터에 그 값을 쓰는 작업 등을 위해서는 회로를 설계해야 한다. 각 작업을 수행하는 물리적인 장치에는 전기 신호를 보내서 어떤 작업을 수행해야 하는지도 알려줘야 한다. 이러한 회로 설계 작업을 데이터 경로 설계라고 한다.

예를 들어, PC는 다음에 실행할 명령어 주소를 뜻하므로 한 번 명령어를 실행할 때마다 4를 더해준 값을 다음 PC로 설정한다. 만약 명령어를 디코딩한 결과 명령어가 브랜치 명령어이고 조건을 만족하여 PC를 변경해야 될 수도 있다. 이때, 어떤 값을 PC로 설정하게 할 것인지를 컨트롤 장치(Control unit)를 통해 Mux(Multiplexer)로 지정할 수 있다. 

아래 사진은 데이터 경로 설계를 어느 정도 마친 결과이다. RegDst 신호를 설정하지 않으면 계산 결과를 레지스터에 쓰지 않으며, MemWrite 신호를 설정하지 않으면 메모리에 쓰지 않게 된다. 즉, 조건을 만족하지 않을 때 어떤 작업을 수행하지 않아야 한다면 컨트롤 신호만 설정하지 않으면 된다는 말이 된다. 특히, 쓰기 작업만 하지 않으면 실제 데이터에 수정이 일어나지 않게 된다.

출처 : 김병기 교수님 강의 노트

데이터 경로 설계에 대한 더 자세한 내용은 다음 를 참고.



파이프라이닝(Pipelining)

빨래를 한다고 생각해보자. 세탁기를 돌리고, 건조기를 돌리고, 다 말려진 빨래를 개고, 옷장에 정리하는 네 단계로 나눌 수 있다. 빨래를 여러 번 해야 할 때 이전의 빨래를 세탁한 뒤 옷장에 정리할 때까지 기다린 뒤에야 다음 빨래를 세탁하도록 한다면 너무 비효율적일 것이다. 그보다는 이전의 빨랫감을 건조기로 옮겼을 때 새로운 빨래를 세탁기로 돌리도록 하는 것이 같은 시간 내에 더 많은 빨래를 할 수 있다.

명령어 실행도 비슷하다. 명령어를 가져와서 디코딩하고 실행한 뒤 메모리 접근을 끝낼 때까지 기다린 뒤 레지스터에 값을 쓰고 나서야 다음 명령어를 실행하기보다, 앞의 명령어를 실행하는 동안 다음 명령어를 디코딩한다면 같은 시간 동안 더 많은 명령어를 실행할 수 있다. 이렇게 명령어를 여러 단계로 나누어 동시에 실행하는 기법을 파이프라이닝 기법이라고 한다. 

명령어 실행은 IF(Instruction Fetch), ID(Instruction Decode), EX(Execution), MEM(Memory), WB(Write Back) 5단계로 나뉜다. IF 단계에서는 명령어를 가져오고, ID 단계에서 명령어를 디코딩하고, EX 단계에서 명령어를 실행하고, MEM 단계에서 메모리에 접근하고, WB 단계에서 ALU 결과 혹은 메모리 접근 결괏값을 레지스터에 쓴다. ID 단계에 있던 명령어가 EX로 이동하고 나면 다음 명령어가 디코딩되므로 쓰기 대상인 레지스터 번호가 소실된다. 이 문제를 해결하기 위해 각 단계 사이에 파이프라인 레지스터를 둬서 값을 저장해둘 수 있도록 한다. 저장하는 값에는 컨트롤 장치의 신호 값도 포함된다.

파이프라이닝 기법은 명령어 실행량을 늘릴 수 있는 장점이 있지만 3가지 해저드 때문에 정상적으로 작동할 수 없다. 구조적 해저드, 데이터 해저드, 컨트롤 해저드가 그 3가지 해저드인데, 이 해저드를 해결하기 위한 몇 가지 추가적인 장치와 기법이 필요하다. 아래 항목에서 해저드를 더 자세히 다루겠다.



해저드(Hazard)

해저드는 구조적 문제 혹은 명령어 간 의존성에 의해서 파이프라이닝 하여 실행하는 도중 잘못된 실행이 될 수 있는 것을 말한다. 각 해저드를 간략하게 설명하면 다음과 같다.

구조적 해저드 : 장치를 함께 사용하면서 발생하는 해저드. lw 명령어가 메모리에 접근하고 있는 상태에서 다른 명령어를 메모리에서 가져오려 한다면 둘이 충돌된다.

데이터 해저드 : 다음 명령어가 이전 명령어의 쓰기 대상 레지스터를 참조하는 경우 이전 명령어가 WB 단계를 거쳐 레지스터 값을 수정하지 않으면 수정되기 전의 값을 참조하게 된다.

컨트롤 해저드 : 브랜치 명령어로 인해서 다른 명령어를 실행하게 된다면 다른 주소의 명령어를 실행하기 전에 브랜치 명령어의 다음 명령어가 이미 디코딩 단계에 와있으므로 잘못된 실행을 하게 된다.

구조적 해저드는 폰 노이만 아키텍처 상으로 어쩔 수 없이 발생하는 문제이다. 해결 방법은 하버드 아키텍처를 적용하거나 lw 명령어 다음에 bubble을 추가하는 것으로 stall 하여 다음 명령어 실행을 지연시키는 방법이 있다.

데이터 해저드는 변경된 레지스터 값을 사용해야 한다는 점에서 발생하는 문제이다. 해결 방법은 구조적 해저드처럼 stall 하거나 명령어 순서를 잘 조절하여 해저드가 일어나지 않게 하거나, 포워딩(forwarding)하는 것이다. 명령어 순서를 조절하는 것은 컴파일러나 어셈블러 수준에서 처리해줄 수 있을 것이고, 현실적으로 stall 시킴으로써 성능을 저하시키는 것보다 포워딩이라는 우아한 해결방법이 있기 때문에 포워딩이 중요하다. 포워딩은 ALU 출력 결과를 가져오는 회로를 추가하여 다음 명령어에서 읽기 대상인 레지스터를 이전 명령어가 쓰기 하려고 할 때 이전 명령어의 출력 결과를 선택하게끔 하는 방법이다.

컨트롤 해저드는 브랜치 명령어로 인해 실제로 브랜치를 하게 될지, 하지 않게 될지 알 수 없다는 점에서 가장 고통스러운 해저드다. 속 편한 방법은 모든 브랜치 명령어 다음에 한 번 stall 하는 것이다. 통계에 따르면 브랜치 명령어는 평균적으로 17%의 비율로 존재하고 stall 하는 경우 한 번의 추가적인 cycle이 필요하므로 평균 CPI가 1.17로 늘어나게 된다. 당연히 이런 해결 방법은 너무 성능 저하가 커서 말이 안 된다. 더 우아한 해결 방법으로는 브랜치가 일어날지 예측하는 방법이다. 브랜치가 일어났을 때만 stall 하고자 하는 것이 목표다. 컴파일러가 휴리스틱 혹은 프로파일링을 통해 미리 목적 코드 상에 브랜치가 일어날 것인지에 따른 결과를 적용해두는 정적 예측과 브랜치 예측 버퍼(또는 브랜치 역사 테이블, Branch History Table, BHT)를 이용한 동적 예측 방법이 있다. BHT에 bit를 두고 브랜치가 일어났을 땐 1로 수정한다. 다음에 브랜치 명령어를 실행할 때는 이 테이블의 값을 참고하여 브랜치 여부를 예측한다. BHT에 2개의 bit를 둬서 예측 성공률을 더 높이기도 한다. 

해저드 해결 방법에 대한 더 자세한 설명은 다음 을 참고.



캐시(Cache)

CPU의 클럭 속도가 매우 빨라지면서 메모리와의 속도 차이가 현저하게 증가하게 된다. 레지스터에 접근하는 속도와 메모리에 접근하는데 걸리는 속도는 100배 이상 차이 난다. 즉, 메모리가 병목 현상을 일으키는 요소가 된 것이다. 이 문제를 해결할 방법은 메모리 접근 횟수를 줄이는 것이고, 그 방법으로 캐시를 추가하게 되었다. 캐시를 추가하면 메모리에 접근할 때에 비해 접근 속도를 매우 증가시킬 수 있다. 메모리의 내용을 캐시에 일정량 복사해둠으로써 캐시에 원하는 데이터가 없을 때만 메모리에 접근하도록 한다. 이때, 캐시에 원하는 내용이 있을 때 캐시 히트(Cache hit), 없을 때 캐시 미스(Cache miss)라고 한다. 

출처 : 김병기 교수님 강의 노트

캐시는 비싸다. 더 큰 용량을 갖추려면 CPU 크기를 크게 만들게 되고 전력을 더 많이 사용해야 한다. 그렇기 때문에 먼저 참조하게 하는 캐시일수록 비싸고 빠르며 용량이 작게, 나중에 참조하게 되는 캐시일수록 저렴하고 덜 빠른 대신 용량이 크게 구성한다. L1 캐시에서 미스가 발생하면 다시 L2에 원하는 데이터가 있는지 참조하고, 또 존재하지 않으면 그보다 하위 레벨의 캐시를 확인한다. 최종적으로 캐시에 데이터가 없다는 것을 알게 되면 메모리에 접근하여 데이터를 다시 복사해온다.

지역성에는 시간 지역성과 공간 지역성이 있다. 시간 지역성이란 최근 접근했던 메모리 주소에 다시 접근하게 되는 현상을 말하고, 공간 지역성은 최근 접근했던 메모리 주소 근처를 다시 접근하게 되는 현상을 말한다. 명령어를 가져오는 것은 공간 지역성이 높고 데이터 참조는 시간 지역성이 높다고 알려져 있다. 캐시 미스가 일어나면 다시 cycle을 돌아 메모리에서 캐시로 복사하는 작업이 수행되어야 하므로 성능상 손해가 발생하지만, 캐시 미스는 10% 확률로 일어난다고 알려져 있으므로 캐시를 썼을 때 성능 향상이 비약적으로 뛰어나다고 볼 수 있다.

캐시가 있으면서 불일치 문제가 생긴다. sw 명령어를 실행하면서 메모리의 값을 수정하게 된다면 캐시에도, 메모리에도 적용해야 한다. 매번 메모리에 적용하는 방식을 Write through 방식이라고 한다. 하지만 이 방법은 메모리 값을 수정할 때마다 메모리에 접근하게 되기 때문에 성능 저하를 크게 일으킨다. Write back 방식은 그 대안이다. 캐시를 교체할 때만 메모리에 변경 사항을 적용하는 방식이다.



가상 메모리와 변환 색인 버퍼

현대 OS에서 가상 메모리는 필수적인 기능이다. 가상 메모리 기법을 통해 프로세스는 전체 논리 주소를 다 사용하고 있는 것처럼 느낄 수 있으며 보다 많은 물리 메모리를 가지고 있는 것 같은 효과도 줄 수 있다. 그러나 가상 메모리 기능을 지원해주는 것은 부하가 많이 든다. 페이지 테이블(Page table)을 구성하여 논리 주소를 물리 주소로 맵핑해주는 작업을 해줘야 하고, 페이지 폴트(Page fault)가 발생했을 때 희생 페이지(Victim)를 결정하는 작업도 해줘야 한다. 메모리 참조가 매우 자주 일어나는 일인 것을 생각했을 때, 이러한 작업을 하드웨어 지원을 받는다면 성능상에 큰 이점이 있기 때문에 보통 하드웨어 수준에서 가상 메모리 기능을 지원한다. 실제로는 메모리 관리 장치를 따로 두나 이 글에서는 가상 메모리 개념과 TLB를 소개하는 수준으로만 다루려 한다.

출처 : 위키백과

가상 메모리는 사용자가 논리 주소만을 이용하여 메모리에 접근하여 사용할 수 있게 하는 방법이다. 실제 메모리에 참조할 때는 페이지 테이블을 통해 물리 주소를 찾아 물리 주소로 접근한다. 이때, 페이지는 논리 주소 단위이고 물리 주소의 단위는 프레임이라고 한다. 페이지 크기는 OS에서 설정하도록 되어 있는데, 보통 4KB 정도로 구성한다. 페이지 크기가 작으면 페이지 테이블이 커지고, 페이지 크기가 커지면 페이지 폴트가 일어날 가능성과 페널티가 커지기 때문에 적당한 크기로 설정해야 한다. 예를 들어 4KB로 페이지 크기를 설정할 경우 12bit를 페이지 오프셋으로 결정하기 때문에 20bit를 가상 페이지 번호로 할당할 수 있게 되고, 2의 20승만큼의 페이지 엔트리가 존재하게 되므로 페이지 테이블의 용량 또한 무시할 수 없는 수준임을 알 수 있다.

페이지 폴트는 물리 메모리에 논리 메모리가 존재하지 않는 상황인데, 물리 메모리가 부족하여 페이지를 보조 기억 장치로 내렸거나, 애초에 물리 메모리에 로드한 적이 없는 페이지를 접근하려는 상황에 발생할 수 있다. 물리 메모리가 부족하여 새로운 페이지를 로드할 수 없다면 어쩔 수 없이 하나의 페이지를 희생 페이지로 정하고 빼내야 한다. 메모리 내용을 상실해서는 안되므로 물리 메모리에서 페이지를 내릴 때는 보조 기억 장치에 그 페이지 내용을 복사하는 방식으로 해결하는데, 보조 기억 장치에 접근한다는 것이 성능 저하를 일으키는 일이므로 페이지 폴트를 최대한 일어나지 않게끔 희생 페이지를 잘 선택하는 것 또한 매우 중요하다. 보통 LRU 알고리즘을 사용한다고 하지만 실제로는 LRU 알고리즘보다 쉬우면서 유사한 효과를 내는 유사 LRU 알고리즘들을 사용한다고 한다.

TLB는 논리 주소를 물리 주소로 변환하는 속도를 빠르게 하기 위해 존재하는 별도의 캐시다. 논리 주소를 물리 주소로 변환하려 할 때 먼저 TLB에 접근하여 원하는 페이지가 있는지 확인하고 존재하지 않는 경우 페이지 테이블을 탐색하여 페이지를 찾는다. 메인 메모리가 아닌 캐시 수준에서 찾아보기 때문에 주소 변환 속도를 훨씬 빠르게 해줄 것이라는 것을 알 수 있다. 페이지 폴트 때와 비슷하게 TLB에 원하는 페이지 엔트리가 없는 경우에도 페이지 테이블에서 TLB로 다시 복사해오는 것이 추후 다시 미스가 발생하지 않게 하는 방법일 것이다.



Polling과 DMA

I/O 작업은 CPU를 벗어나 별도의 장치에서 수행된다. 그런 만큼 I/O 작업이 수행되기 위해서는 별도의 처리 방법이 필요한데, 대표적으로 Polling 방식과 DMA(Direct Memory Access) 방식이 있다. 각 방식의 이름에 특징이 나타나 있는데, Polling은 주기적으로 I/O 장치의 상태 bit를 확인하여 데이터가 쌓였으면 당겨오는(polling) 방식인 Programmed I/O이고, DMA 방식은 장치가 직접 메모리에 접근하는 방식이다. Polling은 인터럽트(Interrupt)에서 인터럽트 핀이 하나일 때 어떤 장치가 인터럽트를 걸었는지 찾을때 사용하는 용어로도 쓰인다. 이 글에서는 앞서 언급한 Programmed I/O로 가정하고 설명한다.

출처 : http://www.yourdictionary.com/dma

Polling 방식은 프로세서의 시간을 많이 낭비하게 된다. 데이터가 쌓였는지 주기적으로 확인해야 하기 때문에 I/O 장치를 확인하는 작업을 수행해야 하는데, I/O 장치의 속도는 CPU만큼 빠르지 않기 때문에 많은 시간을 기다려야 한다. 여기서 좀 더 개선시키고자 고안한 방법이 인터럽트를 이용하여 CPU에게 상태 변화를 알리는 방식이다. 데이터가 쌓였을 때 프로세서에게 인터럽트를 발생시키면 CPU가 주기적으로 I/O 장치를 확인하지 않아도 되므로 많은 시간을 절약할 수 있다.

DMA 방식은 I/O 장치가 직접 메모리에 접근하여 값을 읽거나 쓰는 방식이다. DMA 장치에도 마이크로컨트롤러가 장착되어 있어 CPU처럼 명령어를 수행할 수 있는데, 마이크로컨트롤러에서 직접 메모리에 접근하여 I/O 작업을 수행한다. 그리고 Polling 방식과 비슷하게 작업이 끝났다는 것을 인터럽트로 알려줄 수 있다. 데이터 전송은 bus를 통해 일어나는데 CPU가 작동 중일 때 bus를 이용하려 하면 충돌이 일어나기 때문에 DMA가 bus를 이용하는 동안 CPU가 잠시 bus의 이용을 멈추어야 한다. 이것을 Cycle stealing이라고 한다.

모든 I/O 방식은 오버헤드가 존재한다. Polling 방식은 Busy waiting이 발생하고 인터럽트 주도 방식은 문맥 전환(Context switching)이 발생하며 DMA 방식은 bus cycle을 요구한다. 즉, 큰 비용이 드는 작업임을 알아둬야 한다.



이것으로 컴퓨터 구조에서의 주요 내용들을 살펴보았다. 컴퓨터 구조 강의를 통해 배운 것을 써먹게 되는 것이 어셈블러를 개발해보는 강의다. 어셈블리를 목적 코드로 바꾸는 어셈블러와 목적 코드를 실행해주는 시뮬레이터를 개발하면서 실제로 어떻게 명령어들을 인코딩하고 실행할 때 디코딩하는지를 느껴보는 것이다. 하지만 실제로 실습을 해보지 않고 글로만 배울 때는 큰 효과가 없다고 판단하여 개념 정리 시리즈에 추가하지 않을 계획이다.

다음 편에서는 이러한 컴퓨터 구조와 가장 가까이에 있는 OS를 다뤄보겠다.

알고리즘은 어떠한 문제를 해결하기 위한 여러 동작들의 모임이다. 유한성을 가지며, 언젠가는 끝나야 하는 속성을 가지고 있다. 또한, 다음의 조건을 만족해야만 한다.

입력 : 외부에서 제공되는 자료가 0개 이상 존재해야 한다.

출력 : 적어도 1개 이상의 서로 다른 결과를 내어야 한다.(즉 모든 입력에 하나의 출력이 나오면 안 됨)

명확성 : 수행 과정은 명확하고 모호하지 않은 명령어로 구성되어야 한다.

유한성 : 알고리즘의 명령어들은 끝이 있는 계산을 수행한 후에 종료해야 한다.

효율성 : 모든 과정은 명백하게 실행 가능(검증 가능) 한 것이어야 한다.

출처 : http://www.webopedia.com/TERM/A/algorithm.html

알고리즘을 이야기하면서 복잡도 이야기도 빼놓을 수 없다. 알고리즘의 정의는 수학에서 온 것인데, 컴퓨터로는 유한한 자원상에서 알고리즘을 실행해야 하기 때문에 공간 복잡도, 시간 복잡도가 중요하다. 아무리 빠른 알고리즘이더라도 그 알고리즘을 실행할 컴퓨터의 메모리보다 더 많은 공간을 요구하는 알고리즘이라면 실행할 수 없다. 예를 들어 깊이의 끝을 알 수 없는 검색을 깊이 우선으로 하게 되는 경우 무한히 노드를 검색해나가다가 메모리가 부족하여 실행을 종료하게 될 것이다. 

알고리즘은 보통 분할 정복법(Divide & Conquer), 동적 계획법(Dynamic programming), 탐욕적인 방법(Greedy approach), 백트래킹(Backtracking) 설계 기법을 기반으로 개발된다. 너무 많은 알고리즘들이 있기 때문에 각 항목 별 유명한 알고리즘을 소개하면서 어떤 설계 방법인지 전달하고자 한다.



분할 정복법

풀려는 문제가 큰 문제일 경우 그 문제를 작게 나누기를 재귀적으로 반복하여 작은 문제들로 나누고, 그 문제를 푼 뒤 다시 합쳐나가는 방식의 접근 방식을 분할 정복법이라고 한다. 이렇듯 위에서부터 아래로 문제를 나누어가는 방식을 보고 하향식 접근(Top-down approach)라고도 말한다. 분할 정복법의 대표적 예인 이진 검색과 합병 정렬을 살펴보자.

이진 검색은 이미 정렬되어 있는 배열에 적용할 수 있다. 찾으려는 값과 현재 고려 중인 배열의 중앙에 있는 값을 비교하여 값을 찾기를 성공하거나, 왼쪽 배열에서 다시 찾거나, 오른쪽 배열에서 다시 찾는다. 비교 대상이 지수적으로 줄어들기 때문에 시간 복잡도가 O(logN)이 된다. 정렬되어 있지 않은 배열에서 값을 찾기 위해서는 O(N)의 시간 복잡도였던 것에 비해 비약적으로 빠르다. 정확히 같은 값을 찾는 것이 아니라 하한 값을 찾는 등의 변형도 가능하다. B-tree는 트리의 차수를 크게 높여 인덱스 파일에 대한 디스크 접근을 줄임으로써 검색 속도를 향상한다.

합병 정렬은 O(NlogN)의 시간 복잡도를 가진 정렬 알고리즘이다. 실제로는 캐시 미스에 의한 성능 저하 때문에 퀵 정렬이 많이 이용되나 합병 정렬은 정렬하고자 하는 데이터의 양이 메모리 크기를 초과하더라도 적용 가능하다는 이점이 있다. 정렬하고자 하는 배열을 재귀적으로 반 씩 쪼개나가고 최종적으로 단일 원소가 되었을 때 합병을 시작하는데, 합병을 할 때는 원하는 정렬 순서에 따라 작거나 큰 원소를 먼저 복사해나감으로써 각 합쳐진 배열이 정렬된 배열이 된다. 



동적 계획법

동적 계획법도 분할 정복법과 비슷하게 하위 문제를 이용하여 상위 문제를 푸는 방식의 설계 방법이다. 분할 정복법과의 차이점으로는 나뉘어진 부분 문제가 서로 중첩되는 특징을 가진다는 점이다. 이런 문제에 대해서 동적 계획법은 이전에 계산해둔 것을 재활용함으로써 더 복잡한 문제를 빠르게 계산할 수 있게 된다. 동적 계획법을 기반으로 설계된 알고리즘으로 최장 공통 부분 수열(LCS : Longest Common Subsequence)다익스트라의 최단 경로(Dijkstra's shorted path)플로이드-워셜 알고리즘(Floyd-Warshall Algorithm) 등이 있다.

다익스트라의 최단 경로 알고리즘은 실제 네트워크 망에서 빠르게 패킷을 라우팅하기 위해 쓰이는 알고리즘 중 하나이다. 현재까지 검증된 노드들과 연결된 검증되지 않은 노드를 하나씩 선택해나가면서 최종 목적지까지의 최단 경로를 계산해나간다. 이전에 계산해둔 최단 경로에서 새로운 간선을 추가할 때는 그 간선의 가중치만을 더하면 또다시 최단 경로가 되기 때문에 최적 부분 구조를 가진 문제가 된다. 이 알고리즘은 O(|V|^2)의 시간 복잡도를 가졌지만 우선 순위 큐 등을 활용하여 다음 노드 선택을 더 빠르게 함으로써 O((|E| + |V|)log|V|)의 시간 복잡도로 계산 가능하기도 하다.

다익스트라의 최단 경로 알고리즘과 달리 플로이드-워셜 알고리즘은 전체 노드 간의 최단 경로를 모두 계산하는 알고리즘이다. 노드 하나씩을 더 고려해나가면서 최단 경로를 갱신해나가는 방식으로 구현되기 때문에 O(N^3)의 시간 복잡도를 가진다.




탐욕적인 방법

탐욕적인 방법은 매우 쉽다. 그때그때 최적의 선택만 하면 되는 알고리즘이다. 알고리즘 원칙 자체는 쉬우나 실제 최적해를 찾기 위한 방법으로 적용하기는 어렵다. 실제로 최적해를 계산할 수 있는지 증명해야 하기 때문이다. 그런데 정말 최적해를 계산 가능한 경우가 있다! 대표적으로 최소 신장 트리를 만드는 알고리즘인 프림의 알고리즘과 크러스칼의 알고리즘이 있다. (최소 신장 트리는 모든 노드가 연결되어 있는 상태라는 점에서 의의를 가지는 트리 구조이다.) 프림의 알고리즘은 cycle을 만들지 않는 간선을 하나씩 추가해나가는 방식이고, 크러스칼의 알고리즘은 Disjoint set 자료 구조를 이용하여 정점을 합해나가는 방식이다. 자세한 작동 원리는 해당 항목을 참고.

여기서부터 소개하고 싶은 다른 개념이 있다. 최적이 무엇을 의미하는가이다. 빠른 시간 안에(보통 다항 시간) 답을 찾을 수 있는 문제라면 그 답을 찾으면 된다. 하지만 현실적으로 실제 답을 찾기 어려운 문제도 많다. 답을 찾는 것에 계산 시간까지 포함한다면 좀 더 빠르게 계산되면서도 최적까지는 아니어도 최적에 가까운 답을 구하는 것이 의미가 있는 경우도 많다. 그런 점에서 빠르게 최적 근사 해를 구할 수 있는 알고리즘은 의미가 있는 것이다.

나는 통계적인 방법 또한 이런 개념에서 확장된 것이라 생각한다. 예를 들어 경사 하강법(Gradient descent)은 시작점이 어디인가에 따라 지역해(Local optimum)를 구하게 되기도 한다. 그렇기 때문에 여러 점에서 경사 하강법을 실행해보면서 지역해가 아니게 될 확률을 올리는 식의 접근을 한다. 실제 최적해가 무엇인지 알 수 없다. 그런 경우엔 나름대로 구해본 최적해일 가능성이 높은 해도 의미가 있는 것이다.



백트래킹

백트래킹은 모든 경우의 수를 다 시도해보는 방법이다. 문제를 상태 공간 트리로 가정하고 가능한 경우를 다 탐색해보는 것이다. 트리를 탐색하는 방식이기 때문에 깊이 우선 탐색(Depth First Search, DFS)너비 우선 탐색(Breadth First Search, BFS), 최선 우선 탐색(Best First Search) 등의 여러 가지 탐색 방법을 사용할 수 있다. 회로(Loop)가 존재하는 미로 찾기와 같은 경우 중복 검사를 하지 않으면 깊이가 무한히 깊어질 수 있기 때문에 탐색 방법을 잘 선택하거나 중복 처리를 잘 해야 한다.

백트래킹은 모든 경우의 수를 다 시도해보는 것이기 때문에 느려지기 쉽다. 만약 문제가 비용을 잘 계산할 수 있는 경우 가지 치기를 하는 것이 계산을 줄이는 것이 큰 도움이 된다. 지금까지 100의 비용이 최소 비용이라는 것을 알아냈다면 앞으로 100보다 큰 비용이 들게 될 경우는 더 이상 고려하지 않는 식으로 계산을 줄이는 것이다. 비슷한 맥락에서 가능성이 높은 경우를 먼저 시도해보는 것도 경우의 수를 줄이는 데 큰 도움이 된다. 이를 위해휴리스틱(Heuristic) 함수를 만들어 두는데, 휴리스틱 함수는 절대 과대평가(Overestimation)를 해서는 안 된다. 예를 들어, 목적지까지의 최단 경로를 찾을 때는 직선거리와 같은 것을 쓸 수 있다. 현실적으로 직선거리보다 목적지에 빨리 도달할 수 있는 방법이 존재하지 않기 때문에 과대평가를 하지 않는다는 조건을 만족한다.



다른 흥미로운 알고리즘들

위 설계 기법들에서 제시하는 기본적인 알고리즘들 외에 좀 더 다양한 알고리즘들을 소개한다.

Knapsack problem

최장 증가 수열(Longest Increasing Subsequence)

편집 거리(Edit distance)

Area of polygon

Convex hull

들로네 삼각분할(Delaunay triangulation)

강결합(Strongly connected component)

Ford–Fulkerson algorithm : Maximum flow를 찾는 알고리즘

Hungarian algorithm : Bipartite matching을 찾는 알고리즘



이상으로 알고리즘에 대한 정리를 마친다. 자료 구조에 이어 알고리즘은 추상적 소프트웨어 수준의 공부였다. 그러나 컴퓨터는 추상적인 개념을 실행할 수 없다. 결국 컴퓨터가 실행하는 것은 작성한 소스 코드가 컴파일된 목적 코드이다. 목적 코드가 어떻게 실행되는가에 대해 알아보기 위해 다음 편에서는 좀 더 깊이 들어가 컴퓨터 구조에 대해 알아본다.


일상적으로 쓰고 있던 배열부터 시작하여 얼핏 봐서는 잘 이해가 되지 않는 복잡한 자료 구조까지, 자료 구조의 세계는 방대하다. 학부 수준 자료 구조 강의에서는 보통 힙이나 해싱까지 다루는 것 같다. 트리와 같은 자료 구조들은 배우면서도 왜 배우는지 잘 이해하지 못하는 경우가 많은데, 실제 예를 들어가면서 필요성을 설명해보도록 하겠다.


자료 구조들의 공통적인 특징은 정해진 규칙대로 자료를 다룬다는 것에 있다. 예를 들어 이진 검색 트리는 부모 원소의 왼쪽 자식이 부모보다 작고, 오른쪽 자식이 부모보다 크다는 규칙이 있다. 이러한 규칙 하에 이진 검색이 가능하기 때문에 검색 속도가 빨라질 수 있다. 

모든 경우에 적합한 자료 구조는 없다. 예를 들어 배열은 순차 탐색이 빠르게 가능하지만 중간 삽입, 삭제가 매우 느리다. 연결 리스트는 중간 삽입, 삭제를 빠르게 할 수 있지만 임의 접근이 느리다. 매우 적은 자료를 다루는 경우엔 두 자료 구조상의 차이를 느끼기 어렵지만 데이터베이스와 같이 수 백 만개 이상의 자료를 다뤄야 하는 경우 시간 복잡도 O(N)과 O(logN)의 실제 연산 시간 차이가 어마어마하게 난다. 일반적인 서비스를 운영하는 상황에서 한 물리 서버가 1초에 몇 개의 요청도 처리하지 못한다고 생각해보면 허용될 수 없는 일이라는 것을 알 수 있을 것이다. 그렇기 때문에 주어진 상황에 알맞은 자료 구조를 선택해야 한다.


기억해두자. 우리가 원하는 것은 방대한 자료들을 다루면서도 매우 빠른 시간 안에 원하는 결과를 낼 수 있도록 하는 것이다. 



추상적 자료 구조

추상적 자료 구조에서는 구현을 분리하여 생각한다. 후술 할 스택 자료 구조를 정의할 때 push나 pop과 같은 연산을 정의하고, 그 연산이 연산 복잡도 O(1)을 만족하기만 한다면 어떻게 구현하든 상관하지 않는다. 인터페이스와 구현을 분리하여 생각하자는 것이다. 그렇기 때문에 큐를 배열로 구현된 것을 쓸 수도 있고, 연결 리스트로 구현된 것을 쓸 수도 있다.

종종 자료 구조 전체를 순회하는 작업이 필요할 때가 있다. 구현이 어떻게 되어 있는지 알지 못하기 때문에 이를 해결하는 방법으로 Iterator 패턴이 나타난다. Iterator 패턴이 아주 잘 정의되어 있는 것이 바로 C++ STL의 Iterator이다. 각 컨테이너마다 Iterator가 제공되어 begin부터 end까지 순회 가능하게 한다.

더불어 이 항목에서 설명하고 싶은 것으로 비교자(Comparator)가 있다. 숫자에 대한 비교는 이미 정수론적으로 정의되어 있지만, 문자열 자료만 봐도 어떤 경우에는 문자열 길이가 긴 것이 더 큰 것이라 생각할 수도, 사전 순에서 나중에 있는 것이 더 큰 것이라 생각할 수도 있다. 이렇듯 자료에 대한 비교 자체도 추상적 개념에서 접근해야 한다. 그렇기 때문에 비교 연산이 필요한 자료 구조의 경우 자료 구조 선언 시 비교자를 함께 제공해줘야 하는 경우도 있다. 가장 대표적인 예가 우선순위 큐이다. 



스택과 큐

배열을 제외하고 가장 기본적인 자료 구조라고 할 수 있는 스택과 큐. 선입 후출(First In Last Out), 선입 선출(First In First Out) 방식인 자료구조가 뭐가 유용하겠나 싶겠지만 실제로 곳곳에서 많이 쓰인다. 함수 호출 스택과 메세지 큐가 스택과 큐 자료 구조를 활용한 대표적인 예이다. 웃기게도 이름에 이미 자료 구조가 드러나있다. 자료를 앞 뒤로도 삽입과 삭제를 가능하게 고안한 덱(dequeue)이라는 자료 구조도 있지만, 이 글에서는 스택과 큐까지만 다루겠다.

출처 : http://russell.ballestrini.net/occupy-wall-street-stack-vs-queue/

함수를 호출하면 실행 흐름이 그 함수로 넘어간다. 그 함수에서 또 다른 함수가 실행될 수도 있고, 실행을 끝내고 함수를 빠져나올 수도 있다. 함수에서 빠져나오면 마지막에 함수를 실행했던 곳으로 돌아온다. 이렇게 작동될 수 있도록 하기 위해 프로그래밍 언어론적으로 함수 실행 시 활성 레코드(Activation Record)를 쌓는다고 말한다. 활성 레코드에는 함수를 종료한 뒤 돌아가야 할 주소와 현재 활성 레코드의 크기에 대한 정보가 포함되어 있기 때문에, 함수 스택을 정리할 때 활성 레코드를 참고하여 규칙적으로 운영할 수 있다. 함수 스택에 새로운 활성 레코드를 추가할 때 스택에 활성 레코드를 push 하고, 함수를 종료할 때 활성 레코드를 pop 하는 것이다.

메세지 큐는 다양한 곳에서 응용된다. scanf와 같은 동기 입력 함수를 실행하면 사용자의 입력을 기다리는데, 터미널에 키보드를 입력하면 입력이 수행되는 것을 볼 수 있다. 이는 OS가 키보드에서 생긴 이벤트를 프로세스의 이벤트 큐에 넣어주는 것인데, 이벤트 큐는 메세지 큐의 대표적인 예 중 하나이다. 메세지 큐 자체가 또 하나의 추상적인 개념이기 때문에 프로세스 간 통신(Inter Process Communication)을 비롯한 여러 곳에서 활용되는 개념이다.



연결 리스트

10개의 원소를 가진 배열을 선언해서 사용하고 있다고 생각해보자. 그런데 보관해야 할 원소가 11개로 늘어났다. 갖고 있던 데이터를 버릴 수는 없다. 해결 방법은 11개 이상의 원소를 가질 수 있는 배열을 선언하고 기존의 배열 내용을 복사하는 것이다. 만약 1억 개의 데이터를 보관하고 있던 상황이라면? 배열을 복사하는 것 자체가 엄청난 연산을 요구하는 작업이 된다. 중간중간의 원소에 대해 삽입과 삭제가 빈번히 발생한다면 그 또한 배열 구조에서는 부담이 크다.

출처 : https://en.wikipedia.org/wiki/Linked_list

연결 리스트가 이러한 상황에 유용한 구조이다. 연결 리스트의 원소는 다음 원소를 가리키는 포인터를 갖고 있기만 하기 때문에 중간 삽입이나 삭제를 할 때면 포인터만 잘 처리해주면 된다. 배열을 사용할 때처럼 한 칸씩 원소를 다 밀어내거나 당겨오는 복사 작업을 해야 할 필요가 없기 때문에 유용하다. 그러나 임의 원소 접근이 느리기 때문에 잦은 임의 원소 접근이 필요한 경우엔 적합하지 않다.

파일 시스템의 가용 디스크 블록을 연결 리스트로 관리하는 것이 연결 리스트를 활용한 예 중 하나이다. 디스크의 물리 디스크는 블록이라는 단위로 파일 시스템에 의해 관리되는데 파일이 더 많은 공간을 필요로 하게 될 경우 파일 시스템에게 추가적인 디스크 블록을 요청하여 할당받는다. 파일 시스템은 가용 디스크 블록 리스트에서 하나를 빼내어 파일에게 할당해준다.

데이터베이스에서 없어서는 안 될 자료 구조인 B-tree에서도 단말 노드 간의 연결을 위해 연결 리스트가 사용된다. 단말 노드를 순차적으로 순회하기 위해서는 단말 노드의 연결 리스트를 차례로 순회하면 된다.



트리

트리는 마치 나무가 자라는 것처럼 생긴 자료 구조이다. 이름도 그래서 트리다. 아마 직관적으로 의미를 느끼기 어려워지기 시작하는 자료 구조일 것이다. 배열로 구현할 수도 있긴 하지만, 개념 자체는 배열처럼 생긴 구조가 아니다 보니 순회 방법 또한 따로 정의되어 있어서 더욱 이해를 어려워하는 것 같다. 트리는 connected component이면서 cycle이 존재하지 않는 특수한 그래프라고 볼 수 있다. 문제를 그래프 개념으로 탐색을 적용할 때도 트리 순회 개념이 적용된다. 

출처 : https://www.raywenderlich.com/138190/swift-algorithm-club-swift-tree-data-structure

트리를 자료 구조로서 활용하는 대표적인 예가 검색일 것이다. 익히 알고 있듯이 이진 검색은 O(logN)의 시간 복잡도를 보인다. 이는 검색을 해나갈 때 고려 대상을 1/2씩 줄여나갈 수 있는 방법이기 때문이다. 이 개념을 좀 더 확장하여 정확히 값이 같지 않더라도 비교가 가능한 점을 이용한 것이 데이터베이스의 B-tree 구조이다. 트리 구조를 발명해내지 못했다면 지금처럼 빠르게 동작하는 프로그램들이 만들어질 수 없었을 것이다.

검색을 빠르게 수행하려면 필수적인 것이 트리의 깊이를 최대한 키우지 않는 것이다. 깊이가 깊어지면 깊어질수록 비교를 수행해야 하는 횟수가 늘어난다. 트리 전체가 메모리에 올라와있는 경우에는 큰 문제가 되지 않지만 디스크에 저장된 트리(데이터베이스의 인덱스 트리)는 비교를 수행할 때마다 디스크에 접근해야 하기 때문에 깊이가 1만 증가해도 매우 느려진다. 그렇기 때문에 균형 트리(Balanced Tree)를 유지하는 것이 중요한 문제가 되며, AVL 트리를 배우는 것은 자가 균형 트리를 유지하는 방법 중 하나를 알아보기 위함이다.

은 완전 이진 트리(Complete Binary Tree)이며 최댓값이나 최솟값을 빠르게 찾아내기 위한 자료 구조로 활용된다. 신묘한 방법으로 노드를 추가하거나 삭제할 때마다 일정한 처리를 함으로써 루트 노드는 항상 최댓값이거나 최솟값이 되도록 한다. 이 방식을 이용하여 우선순위 큐를 구현할 때 힙을 이용하여 구현하기도 하며, 힙 정렬 또한 힙 구조를 이용한 정렬 방법이다.

최소 신장 트리(Minimum Spanning Tree)라는 개념도 있다. 네트워크 망을 그래프 개념으로 볼 때 최소 신장 트리를 만들면 최소한 각 네트워크 노드들이 모두 연결되어 있음을 보장한다. 파일 시스템의 디렉토리 구조 또한 트리 구조로 이루어져 있다. 이렇듯 트리 구조는 다양한 분야에서 그 개념을 적용할 수 있다.



그래프

그래프는 일련의 꼭짓점(Vertex)들과 그 사이를 잇는 변(Edge)들로 구성된 조합론적 구조이다. 정의가 간단한만큼 세부 종류도 다양하다. 꼭짓점은 노드(Node)라고 부르기도 한다. 변이 방향성을 가지는지에 따라 유향 그래프 또는 무향 그래프로 분류하고 꼭짓점을 이분할 수 있는지에 따른 이분 그래프, 변과 변이 교차하지 않을 수 있는지에 따른 평면 그래프 등 다양한 그래프가 존재한다.

출처 : http://www.introprogramming.info/english-intro-csharp-book/read-online/chapter-17-trees-and-gra

그래프를 자료 구조로써 표현하는 방법도 여러 가지다. 2차원 배열을 이용하여 인접 행렬로 표현하기도 하고, 각 정점마다 연결된 정점을 리스트로 표현하는 인접 리스트로 표현하기도, 변들을 리스트로 가지는 엣지 리스트로 표현하기도 한다. 희소 그래프일 경우 인접 행렬보다 인접 리스트로 표현하는 것이 메모리를 많이 절약할 수 있으나 인접 리스트로 구현할 경우 특정 노드와 노드 사이가 연결되어 있는지 확인하는 것이 느리기 때문에 상황이나 알고리즘에 따라 잘 선택해야 한다.

문제를 그래프로 형상화하여 푸는 경우가 많기 때문에 알고리즘을 많이 생각해야 하는 문제를 만나게 될 경우 그래프를 심심찮게 만나게 된다. 한 노드에서 다른 노드까지의 이동 비용을 변의 가중치로 두고 최단 거리 경로를 구하는 문제가 익숙할 것이다. 이 문제의 실제 예는 네트워크 망에서 패킷을 빠르게 이동하기 위한 라우팅 알고리즘이다. 여기서 다익스트라의 최단 경로 알고리즘이 등장한다. 알고리즘까지 다루는 것은 이번 편의 주제를 벗어나므로 다음 편에서 다루겠다.



해시 테이블

해시 테이블은 해시 함수로 인덱스를 결정하여 O(1)의 시간 복잡도로 원하는 데이터를 찾기 위해 고안된 자료 구조이다. 어떤 값을 해시 함수를 거쳐 나온 결과 값을 인덱스로 하여 해당 인덱스의 버킷으로 바로 접근하는 아이디어이다. 당연히 해시 함수가 고르고(uniform) 무작위(random)하게 값을 해시해야 한다. 어떤 해시 함수가 고르고 무작위한지 증명하는 것은 매우 어려운 문제이기 때문에 완전히 고르고 무작위한 해시 함수를 만들기보다는 적당한 해시 함수를 사용하면서 충돌을 잘 처리해주는 방식을 사용한다. 충돌 처리를 위해서는 Overflow chaning, Open hashing 등의 방법을 사용한다.

출처 : http://vhanda.in/blog/2012/07/shared-memory-hash-table/

앞서 말한 정적 해싱 방법은 충돌 처리가 실용적이지 않기 때문에 실제 자료 구조로 쓰기 위해서는 동적 해싱 방법을 사용하며, 동적 해싱을 이용한 자료 구조는 데이터베이스의 인덱스 방식 중 하나로 활용된다. 자꾸 데이터베이스를 예시로 쓰는 것 같지만, 실제로 데이터베이스는 자료 구조를 잘 활용해야 하는 프로그램 중 하나이기 때문에 자료 구조의 예로 많이 들 수밖에 없다. 동적 해싱 방법으로는 Exendible hashingLinear hashing 등이 존재한다.



이것으로 몇 가지 주요 자료 구조들을 알아보았다. 다음 편에서는 알고리즘을 다루겠다.


이제부터는 Smart Contract 로 들어가기로 한다.
Ethereum 이 비트코인과 가장 크게 차별화되는 특징이기도 하며, Public/Consortium/Private Blockchain 에 공통으로 적용되어 기존 C/S 환경 대비 가장 큰 변화를 가져다줄 수 있는 기술이기도 하다.

일단 얼마전에, 블록체인의 응용방법을 주제로 한 Blockchain 초급 개발자를 대상으로 생각하여 만든 자료를 사용하여 설명 해보겠다.

▌Smart Contract 의 개요

쉽고 멋들어지게 일반화된 용어로 Smart Contract 를 표현한 뉴스기사나 글들이 참 많다. 그래서일까, 사람들이 Smart Contract (스마트 계약)이라는 용어를 나름 머릿속으로 해석하여 정말 무슨 계약 문서같은것이 블록체인에 담겨있다는 생각을 하는 이들도 많다. 그도 그럴것이, Blockchain 을 응용한 매우 낮은 수준의 응용 방법 중 대표적인 것이 "Proof of Existence" 였고, "진본증명" 이나 "외부거래증명" 같은 Use-Case 를 다수 접해온 Blockchain 초심자들이 상상해내기 딱 좋은 용어의 조합이기 때문이라고 생각한다.

Nick Szabo (닉 싸보)

Smart Contract 의 최초 발안자는 Nick Szabo 이다. 이양반은 Computer Scientist 이며 암호학과 법학에 조회가 깊고 경제학에도 관심이 많은, 보통 사람이 대하기에는 힘겨운 그런 사람이다.

1994년, Smart Contract 라는 개념에 대해 처음으로 발표하게 되고
1997년, The Idea of Smart Contract 라는 글로 실제로 이러한 Smart Contract 를 어디에 적용하면 좋을지에 대한 아이디어를 낸다

▌Smart Contract 키워드

Nick Szabo 가 말하는 Smart Contract 의 목적은, "신뢰할 수 없는 컴퓨터 네트워크환경" 에서 "(Machine 간에)고도로 발달된 자동 계약 이행 방법" 을 제시하는 것이다. 이러한 개념은 블록체인 기술을 만나면서 빛을 발하게 되었고, Ethereum 이 "Turing Complete Blockchain" 이라는 개념으로 Blockchain 을 진화시켜가며 Nick Szabo 의 아이디어를 실현시켜 준다.

Nick Szabo 는 블록체인 내의 Smart Contract 를 아래와 같이 이야기한다.
 
    ▪  코드 조각 이다
    ▪  공유장부와 상호작용할 수 있는 인터페이스
    ▪  Transaction 을 보내면 코드조각의 함수를 실행
    ▪  실행된 함수는 장부에서 값을 읽거나 씀

그리고, 금융을 의식해서인지, 어느 강연에서는 이렇게 이야기한다.


이제 조금 감이 왔을 것이라고 믿는다. 조금만 더 이해를 돕기 위한 이야기를 해보자.

▌Smart Contract 의 정체

Nick 이 말한대로이다. Smart Contract 는 계약가 아니라 코드 조각이다. 

아래는 실제 Solidity 언어로 구현 한 Smart Contract (Code) 이다.

contract CrowdFunding {
  struct Funder {
    address addr;
    uint amount;
  }
  struct Campaign {
    address beneficiary;
    uint fundingGoal;
    uint numFunders;
    uint amount;
    mapping (uint => Funder) funders;
  }
  uint numCampaigns;
  mapping (uint => Campaign) campaigns;
  function newCampaign(address beneficiary, uint goal) returns (uint campaignID){
    campaignID = numCampaigns++; 
    campaigns[campaignID] = Campaign(beneficiary, goal, 0, 0);
  }
 
 ...

이러한 Smart Contract Code 는 Ethereum 의 경우, Solidity, Serpent, LLL, Mutan 의 언어로 쓰여질 수 있는데, 현재는 Solidity 를 주로 밀고 있으며 문법은 JavaScript-Like 하다. Serpent 는 Frontier 가 Release 되기 전까지만 해도 C 언어와 유사한 문법이고 Solidity 의 완성도가 많이 떨어져서 두루두루 함께 썼지만 지금은 Solidity 의 승이다. Mutan 은 접은지 꽤 됐고, LLL 은 아직도 Assembly-Like 하게 Low Level 로 개발하고 Debug 하는데에 일부 해외 개발자들이 사용한다.

위에 보이는대로, Smart Contract 는 "변수"도 있고 "구조체"도 있고 "함수"도 있는 코드 이다. 물론 좀더 들어가면 'Storage 변수와 Memory 변수'로 변수의 종류가 나뉘고 Contract 간의 Address 기반 호출과 DELEGATECALL 등이 들어가면서 기존 프로그래밍 방식과 다른 점들이 많이 튀어나오긴 하지만 그래도 코드이다.

이러한 Smart Contract Code 는 Compile 과정을 거쳐 Byte Code 로 변환된다.


위 Bytecode 는 Solidity Realtime Compiler 를 통해 컴파일된 결과이다(도구 등에 대해서도 다음번에 다룰 것이다). 모두 16진수로 된 코드이며, 이 Bytecode 를 to: 주소가 없이 Payload (data: ) 로 할당하여 Blockchain 에 Transaction 을 날리면, Miner 에 의해 Block 이 생성되고, 이러한 Transaction은 Contract Creation Transaction 으로 간주되어 Transaction Receipt 의 contractAddress: 필드에 생성(배포)된 Contract 의 주소를 넣어서 리턴해주게 되어있다.

다음은 이러한 Smart Contract 의 응용 흐름에 대해 간략히 이해 해보자

▌Smart Contract 의 응용 흐름

Smart Contract Code 는 크게 [Creation/Deployment] [Invoke by Message] [Call] 의 응용방식으로 나뉜다.
우선 아래 그림을 보면서 해당 Smart Contract의 응용 흐름을 이해 해보자.


▪   Smart Contract 개발환경
 Smart Contract 개발환경은 개발도구와 Compiler 까지를 포함한 범위를 표시한다. Code 를 작성하고 컴파일 하면 모든 컴파일러는 [Byte Code] 와 [Function Signature], [ABI] 를 최소한 벹어낸다. 

  Byte Code 는 이미 위에서 설명한 것 처럼 Smart Contract Code 를 컴파일 한 결과이며, Blockchain 에 Contract Creation Transaction 을 발생시켜 배포하거나 Contract 로의 Message Tx 이나 Call 을 통해 EVM 위에서 실행된다.

  Function Signature 는 Contract 내의 함수 이름의 SHA3 한 Hash 값의 4바이트 값으로, Contract 의 함수를 실행시킬 때 Transaction 의 to: 주소에는 Contract Address 를, data: 부분에는 이 method signature 4바이트와 함께 파라미터 값이 payload 로 들어간다. JSON RPC API 를 통해 직접 실행시킬때에는 신경써야 하지만 web3.js 를 통해 contract 를 실행할때에는 신경 쓸 필요 없다. 아래 ABI 때문에 가능하다.

  ABI(Application Binary Interface) 는 특정 언어나 플랫폼에 종속되지 않은 방식으로 기술된 Application Interface 에 대한 정의이다. 쉽게 말하면, 이 ABI 정의를 컴파일러 혹은 ABI Generator 가 벹어내는데, 이 ABI 에는 Smart Contract 의 함수와 Parameter 에 대한 Metadata 가 정의되어있다. 이 ABI 를 갖고 JavaScript 언어 기반의 어플리케이션을 만들 때 객체를 만들게 할 수 있고, 쉽게 그 객체의 Method를 호출하는것 만으로 Contract 의 함수가 호출되도록 할 수 있는 것이다. 현재 Ethereum 은 web3.js 와 함께 JavaScript 응용에서 쉽게 ABI 로 객체를 만들어 사용하도록 지원하며, 1.4.0 이후의 go-ethereum 에서는 Go Native 언어 기반의 응용에서 Smart Contract 를 쉽게 Binding 가능하도록 ABI 기반으로 Go Code 를 생성 해주는 ABIGen 을 제공하고있다.

▪   Blockchain Engine
 geth 나 parity, eth 와 같은 Ethereum Node 를 의미한다. 결국 모든 Smart Contract 와 관련한 Transaction 처리와 Contract 실행을 위한 EVM 은 Node 가 갖고있다.

▪   Applications
 Smart Contract 는 Logic 만을 갖고있을 뿐이다. 사용자나 외부 시스템과의 상호작용을 위해서는 당연히 Application 이 필요하다. HTML+CSS+JavaScript 가 되었건 Application Server 가 되었건 Wallet 이건 간에, Ethereum 과의 Interface 를 통해 Smart Contract 와 상호작용하는 Application 에 해당하는 부분이다. Contract 파트를 뺀 Dapp 부분 정도로 봐도 된다.


[1] Contract Creation/Deployment

 일단 bytecode 가 생성되면, ABI 기반의 객체를 통하건 RPC API 를 통하건 Ethereum Network 으로 Contract Creation Transaction 을 날릴 수 있다. 이때 Transaction 에서 to: 주소는 빼고 payload 에 bytecode 를 넣고, 충분한 Gas 를 넣어 보내면(Gas Estimation 기능을 잘 활용해야 한다. 아니면 좀 과하게 넣고 남기면 된다) Contract 가 생성되게 된다.

 ABI 를 통해 생성한 객체의 new() 를 통해 Contract 를 Create 했다면 생성이 완료된 시점(Transaction 이 처리되고 블록으로 묶여서 Import 된 시점)에 callback 이 호출되며 그때 Transaction Receipt 를 보면 contractAddress 에 생성된 Contract 의 주소가 들어가 있게 된다. 이후 부터는 이 주소를 통해 Contract 를 사용하면 되는것이다.

[2] Message Transaction

 위에서 생성한 Contract 의 함수를 실행하는 Transaction 이다. JSON RPC API 로 호출할때에는 payload 에 4바이트의 Function Signature 를 제일 먼저 쓰고 그 다음부터 32바이트 단위의 파라미터 값들을 넣어서 보내게 된다. ABI 를 통한 Contract 객체는 단순히 객체의 Method 를 호출하듯 하면 된다. 

 이 Message Transaction 은 단순히 모든 함수 호출에 사용하면 낭패를 볼 수 있다. Message Transaction 또한 Transaction 이며, Transaction 을 발생시키면 당연히 Gas 가 소모된다. 단순히 현재 상태값을 조회하는 함수를 호출하거나 테스트 목적으로 함수를 호출한다면 Message Transaction 을 발생하면 안된다. 이때는 다음 설명하는 Call 방식을 사용해야 하며, Message Transaction 은, Smart Contract 를 통해 Global State 를 변경해야하는 경우 즉, 값이 변경되어야 하는 경우에만 사용하여야 한다. 나중에 상세히 설명하겠지만, Contract 개발자도 이러한 상태변화가 없는 함수는 constant 로 선언하여야 ABI 로 아무생각없이 함수를 호출하는 응용 사용자들이 Gas 를 소모당하지 않게할 수 있다.

[3] Call

 Contract 의 함수를 호출하는 두번째 방법이다. [2] 에서도 잠깐 언급했지만, Ethereum 의 Global State 에 변화를 주지 않는 함수를 Gas 소모 없이 호출하려면 이 call 을 사용해야 한다. Contract 함수를 Call 하게 되면, Transaction 을 발생시키지 않고 자기 Node 내에 이미 저장되어있는 Smart Contract 를 Local 에서 실행시킨다. constant 함수가 아니더라도 Transaction 발생 없이 함수를 실행시킬 수 있으나 Global State 에는 영향을 주지 않고, call 이 끝난 시점에 모든 state 는 원상복귀된다. 


아.. 오늘은 그냥 "이해" 인데 또 글이 길어졌다.. 더이상 쓰다가는 내일 출근에 지장을 줄 터, 이만 줄이고 앞으로도 많은 내용을 올려야 할 듯 하니 다음 글로 미루어 두도록 하겠다~ ^^;;


▌그래서..

Smart Contract 는, 보이는 대로 블록체인에 배포되는 Code 이다. 그래서 IBM 의 경우, OBC-Peer 를 만들 때 부터 Smart Contract 라는 개념을 가져다 쓰지만 용어는 좀더 Clear 하게, "Chain Code" 라고 부른다. Nick Szabo 에게는 좀 미안하긴 하지만, Contract 라는 표현 보다는 좀더 직관적이지 않나 싶다.

그러나 앞으로의 글들을 보다보면 무조건 Chain Code 라고 하기 보다는 어떤 때는 Contract 라는 용어가 더 맞는것 같다는 느낌이 종종 들 것이다. Public Blockchain 과 Private Blockchain 에서 Smart Contract 가 가져야할 특징과 역할이 약간 다르기 때문인데, 이건 앞으로의 글을 이해하면서 느껴보면 되겠다.

아.. 이젠 졸립다.. 그럼 이만..



출처: http://goodjoon.tistory.com/261 [Good Joon]

make & Makefile 이란?

SHELL 에서 컴파일을 해보셨다면, make 명령어로 컴파일을 실행하는 경우를 자주 보셨을 것입니다. Makefile이 있는 디렉토리에서 make 만 치면 컴파일이 실행된다?? 어떻게 이런 일이 일어날 수 있는 것일까요? 

왜냐하면 make는 파일 관리 유틸리티 이기 때문이지요.

make

파일 간의 종속관계를 파악하여 Makefile( 기술파일 )에 적힌 대로 컴파일러에 명령하여 SHELL 명령이 순차적으로 실행될 수 있게 합니다.

그럼 이제 Makefile도 어떤 역할을 하는지 아시겠죠?

make를 쓰는 이유

하지만 여기서 의문점이 있을 수 있습니다. 그냥 컴파일러로 컴파일하면 되지 왜 굳이 Makefile을 만들고 make명령을 실행해야 하나?

make을 쓰면 다음과 같은 장점이 있습니다.

  1. 각 파일에 대한 반복적 명령의 자동화로 인한 시간 절약
  2. 프로그램의 종속 구조를 빠르게 파악 할 수 있으며 관리가 용이
  3. 단순 반복 작업 및 재작성을 최소화


글로만 보니 이해가 잘 안되시죠? 그럼 make의 필요성을 느껴보기 위해 기본적인 컴파일 과 make를 이용한 컴파일을 직접 해봅시다!

예제


이제 위의 종속관계 표를 보며 diary_exe라는 실행 파일을 만들어 봅시다!


1. diary.h 헤더파일 만들기

세 개의 c파일이 include 할 헤더파일을 생성해 봅시다!

vi diary.h ( 헤더 파일 생성)


코드 



2. 재료로 사용 될 C파일 만들기

vi memo.c 
vi calendar.c 
vi main.c

코드

  1. memo.c 
  2. calendar.c 
  3. main.c 



3. 생성된 파일 확인하기

위의 모든 파일을 생성 했다면 제대로 생성 되었는지 ls명령어로 확인해 줍시다.

$ ls 



자! 모든 파일을 생성했다면 이제 두가지 방법으로 컴파일을 해보겠습니다.


기본적인 컴파일 과정

먼저 기본적인 방법으로 컴파일을 해봅시다. 컴파일은 gcc 를 이용하였습니다.

1. c파일에서 object 파일 생성하기

아래의 명령어로

gcc -c -o memo.o memo.c 
gcc -c -o calendar.o calendar.c 
gcc -c -o main.o main.c

각 c파일에서 object 파일을 생성해 줍니다.

여기서 -c 옵션은 object 파일을 생성하는 옵션이고, 
-o 옵션은 생성 될 파일 이름을 지정하는 옵션입니다.

여기서는 -o 옵션을 넣지 않아도 object 파일이름이 (c파일이름).o로 자동 생성 됩니다. 
하지만 실행 파일 생성시 -o 옵션을 넣지 않으면 모든 파일이 a.out 이라는 이름을 가지게 되므로 여러 개의 실행 파일을 생성해야 할 때 효율적인 옵션입니다.


그럼 
ls 명령어로 object 파일이 제대로 생성되었는지 확인해 줍시다. 



2. 각 object파일을 묶어 컴파일을 통해 diary_exe 실행파일 생성하기

이제 실행 파일을 생성해 봅시다!

gcc -o diary_exe main.o memo.o calendar.o

여기서 object 파일들의 순서는 상관이 없습니다. 
위의 명령어를 실행하면 드디어 diary_exe 실행파일이 생성됩니다!!!!


ls 명령어로 확인해 봅시다. 


3. 결과 확인하기

바르게 생성되었다면 아래와 같이 결과가 나오는지 확인해 보세요.

$./diary_exe


기존의 컴파일 과정이 여기서는 그리 귀찮지 않습니다. 모든 c파일을 각각 컴파일 해도 3번만 명령해 주면 되니까요. 하지만 만약 하나의 실행파일을 생성하는데 필요한 c파일이 1000개라면..?? 1000개의 명령어가 필요합니다. 이러한 상황을 해결해 주는 것이 바로 make 와 Makefile입니다!


make를 이용한 컴파일 과정

그럼 이제 Makefile 을 먼저 어떻게 만드는지 알아 본 후 make 명령으로 위의 파일들을 컴파일 해봅시다.


Makefile 의 구성

Makefile은 다음과 같은 구조를 가집니다.

-목적파일(Target) : 명령어가 수행되어 나온 결과를 저장할 파일 
-의존성(Dependency) : 목적파일을 만들기 위해 필요한 재료 
-명령어(Command) : 실행 되어야 할 명령어들 
-매크로(macro) : 코드를 단순화 시키기 위한 방법


Makefile의 기본구조

위의 구성에서 말한 요소들은 실제 Makefile 코드에서 다음과 같이 배치됩니다.


Makefile 작성규칙

목표파일 : 목표파일을 만드는데 필요한 구성요소들 
(tab)목표를 달성하기 위한 명령 1 
(tab)목표를 달성하기 위한 명령 2

// 매크로 정의 : Makefile에 정의한 string 으로 치환한다. 
// 명령어의 시작은 반드시 으로 시작한다. 
// Dependency가없는 target도 사용 가능하다.


make 예제 따라해보기

자! 이제 실제로 Makefile을 만들어 봅시다~

$ vi Makefile

여기서 더미타겟 은 파일을 생성하지 않는 개념적인 타겟으로

$ make clean

라 명령하면 현재 디렉토리의 모든 object 파일들과 생성된 실행파일인 diary_exe를 rm 명령어로 제거해 줍니다.


이제

$ make

로 Makefile을 실행해 줍니다.




명령어들이 실행 되면서 타겟파일이였던 diary_exe 가 만들어졌습니다!!

실행결과는 기본적인 컴파일 과정에서 본 결과와 동일함을 알 수 있습니다.

그런데 아직까지 기본적인 컴파일 과정을 묶어둔 것 외에는 특별한 점이 없어 보입니다.

위의 코드를 더욱 단순화 시키기 위해서 매크로(macro)를 사용해 봐요~


Makefile 개선하기 : 매크로 사용

매크로는 생각보다 간단합니다. 위의 코드에서 중복되는 파일 이름들을 특정 단어로 치환하면 됩니다.

마치 C언어에서 #define을 하는 것과 비슷한 원리입니다.


Makefile 매크로 사용 예제


작성 규칙

  1. 매크로를 참조 할 때는 소괄호나 중괄호 둘러싸고 앞에 ‘$’를 붙인다.
  2. 탭으로 시작해서는 안되고 , :,=,#,”” 등은 매크로 이름에 사용할 수 없다.
  3. 매크로는 반드시 치환될 위치보다 먼저 정의 되어야 한다.

여기서 -W -Wall는 컴파일 시 컴파일이 되지 않을 정도의 오류라도 모두 출력되게 하는 옵션입니다.

make clean 
vi Makefile //매크로 사용 예제처럼 수정 
./diary_exe

로 전과 같은 결과가 나오는지 확인해 보세요~

여기서 더 코드를 단순화 시키기 위해서 사용자가 직접 정의하는 매크로가 아닌 미리 정의된 내부 매크로를 한번 사용해 보겠습니다!


Makefile 개선하기2 : 내부 매크로 사용



!? 
내부 매크로를 사용하였더니 코드가 굉장히 단순해 졌습니다! 
여기서 사용된 내부 매크로를 한번 살펴봅시다.

  1. “$@” : 현재 타겟의 이름
  2. “$^” : 현재 타겟의 종속 항목 리스트

이를 바탕으로 위의 코드를 한번 처음부터 끝까지 해석해 봅시다!

1. gcc 컴파일러를 이용 
2. 사소한 오류까지 출력 
3. 최종 타겟 파일은 diary_exe 
4. OBJECT 로 정의할 파일들은 memo.o main.o calendar.o 
5. all 은 현재는 사용하지 않았지만 타겟 파일이 여러개 일때 사용됩니다. 
6. 타겟 파일을 만들기 위해 OBJECT 들을 사용한다.( 단 OBJECT 파일이 없다면 OBJECT 파일과 이름이 동일한 C파일을 찾아 OBJECT파일을 생성한다. ) 
7. gcc -o diary_exe memo.o main.o calendar.o과 동일 
8. 더미타겟

이해가 되셨나요?


내부 매크로는 본 예제에서 쓰인 것 보다 훨씬 많기 때문에 리스트를 한번 찾아보고 다른 예제를 해보시면 도움이 됩니다 :-)


정리

이처럼 Makefile을 생성하여 make 명령을 사용하면 다음과 같은 장점이 있습니다.

  • 입력파일 변경 시 결과파일 자동 변경을 원할 때 지능적인 배치작업 수행
  • 일일이 gcc 명령어를 안치고도 간단하면서 용이하게 컴파일을 진행할 수 있음

우리 모두 make 로 더 쉽게 컴파일 해요~.~



출처: http://bowbowbow.tistory.com/12#기본적인-컴파일-과정 [멍멍멍]

+ Recent posts