상세 컨텐츠

본문 제목

[Architecture] 클린 아키텍처 - 소프트웨어 구조와 설계 원칙 2부 : 프로그래밍 패러다임

프로그래밍/Architecture

by jisooo 2024. 1. 14. 17:38

본문

 

 

#클린아키텍처 #cleanArchitecture #구조적프로그래밍 #객체지향프로그래밍 #함수형프로그래밍

패러다임이란,

프로그래밍을 하는 방법으로,

대체로 언어에는 독립적이다.

패러다임은 어떤 프로그래밍 구조를 사용할지,

그리고 언제 이 구조를 사용해야 하는지를 결정한다.

현재까지 이러한 패러다임에는 세가지 종류가 있다.


  • 구조적 프로그래밍
    • 제어흐름의 직접적인 전환에 대해 규칙을 부과한다.
  • 객체지향 프로그래밍
    • 제어흐름의 간접적인 전환에 대해 규칙을 부과한다.
  • 함수형 프로그래밍
    • 할당문에 대해 규칙을 부과한다.(불변성)


각 패러다임은 위와같이 프로그래머에게서 어떠한 권한을 박탈한다.

즉 부정적인 의도를 가지는 일종의 추가적인 규칙을 부과한다.

결국 패러다임은 무엇을 해야 할지를 말하기보다는 무엇을 해서는 안되는지를 말해준다.

그러면 아키텍처와 패러다임의 관계는 무엇일까?

우리는 아키텍처 경계를 넘나들기 위한 메커니즘으로 다형성을 이용한다.

우리는 함수형 프로그래밍을 이용하여 데이터의 위치와 접근 방법에 대해 규칙을 부ㅘ한다.

우리는 모듈의 기반 알고리즘으로 구조적 프로그래밍을 사용한다.

각 세 가지 패러다임과 아키텍처의 세 가지 큰 관심사

(함수, 컴포넌트 분리, 데이터 관리)가 어떻게 서로 연관되는지 주목하자.


구조적 프로그래밍이 뭔지 생소했는데,

구조적 프로그래밍은 절차적 프로그래밍의 하위 개념이라고 한다.

책에서는 네덜란드의 컴퓨터 과학자 데이크스트라가 발견한 구조적 프로그래밍에 대하여 설명한다.

"증명"이라는 수학적인 원리를 적용하여,

프로그래밍이 작은 세부사항이라도 간과하면 결국엔 예상 외의 방식으로 실패하는 문제점을 해결하고자 하였다.

그의 비전은 공리, 정리, 따름정리, 보조정리로 구성되는 유클리드 계층구조를 만드는 것이었다.

즉 프로그래머는,

입증된 구조를 이용하고,

이들 구조를 코드와 결합히시켜,

그래서 코드가 올바르다는 사실을 스스로 증명하게 되는 방식이었다.

또한 goto문장이 모듈을 더 작은 단위로 재귀적으로 분해하는 과정에 방해가 되는 경우가 있다는 사실을 발견한다.

반면, if/then/else와 do/while과 같이,

분기와 반복이라는 단순한 제어 구조에서는

goto문을 사용하더라도 모듈을 분해할 때 문제가 되지 않는 것을 발견했다.

이러한 제어구조는 순차실행과 결합했을 때 특별하다는 것을 깨달았다.

결국 프로그래밍은 순차, 분기, 반복이라는

세가지 구조만으로 표현할 수 있다는 사실을 증명했다.

데이크스트라는 단순한 열거법을 통해

순차 구문이 올바름을 입증할 수 있다는 사실을 보여준다.

이 기법에서는,

각 순차 구문의 입력을 순차 구문의 출력까지 수학적으로 추적한다.

분기의 경우 열거법을 재적용하는 방식으로 처리했다.

먼저 분기를 통한 각 경로를 열거하고,

결과적으로 두 경로가 수학적으로 적절한 결과를 만들어낸다면, 증명은 신뢰할 수 있게 된다.

반복을 증명하기 위해서는 귀납법을 사용하였다.

열거법에 따라 1이 올바름을 증명하고,

그렇게 N의 경우가 올바르다고 가정할 때,

N+1의 경우도 올바름을 증명하며 이경우에도 열거법을 사용했다.

또한 반복의 시작, 종료 조건도 열거법을 통해 증명했다.

결국 오랜기간동안 컴퓨터 언어가 진화하면서

goto문장은 계속 뒤편으로 밀려났또 마침내 거의 사라졌다.

대다수의 현대언어는 goto문장을 포함하지 않는다.

현재 우리 모두 구조적 프로그래머이며,

여기에는 선택의 여지가 없다.

제어흐름을 제약 없이 직접 전환할 수 있는 선택권 자체를

언어에서 제공하지 않기 때문이다.

- 기능적 분해

구조적 프로그래밍을 통해,

모듈을 증명 가능한 더 작은 단위로 재귀적으로

분해할 수 있게 되었고,

이는 결국 모듈을 기능적으로 분해할 수 있음을 뜻했다.

즉, 거대한 문제 기술서를 받더라도,

문제를 고수준의 기능들로 분해할수 있다.

그리고 이들 각 기능은 다시 저수준의 함수들로 분해할 수 있고,

이러한 분해의 과정을 끝없이 반복할 수 있다.

이를 토대로 구조적 분석이나 구조적 설계기법

1970 ~1980년대에 걸쳐 인기를 끌었다.

이들 기법을 사용하면,

대규모 시스템을 모듈과 컴포넌트로 나눌 수 있고,

더 나아가 모듈과 컴포넌트는

입증할 수 있는 아주 작은 기능들로 세분화 할 수 있다.

- 테스트

데이크스트라는,

"테스트는 버그가 있음을 보여줄 뿐,

버그가 없음을 보여줄 수는 없다."

라고 말했다.

다시 말해,

프로그램이 잘못되었음을 테스트를 통해 증명할 수는 있지만,

프로그램이 맞다고 증명할 수는 없다.

테스트가 보장해줄수 있는 것은

프로그램이 목표에 부합할 만큼은

충분히 참이라고 여길 수 있게 해주는 것이 전부다.

- 결론

구조적 프로그래밍은

프로그램을 증명 가능한 세부 기능 집합으로 재귀적으로 분해할 것을 강요한다.

그러고 나서 테스트를 통해

증명 가능한 세부 기능들이 거짓인지를 증명하려고 시도한다.

이러한 구조적 프로그래밍이 오늘날까지 가치있는 이유는,

프로그래밍에서 반증 가능한 단위를 만들어낼 수 있는 바로 이 능력 때문이다.

또한 아키텍처 관점에서는,

기능적 분해를 최고의 실천법 중 하나로 여기는 이유이기도 하다.

소프트웨어 아키텍트는

모듈, 컴포넌트, 서비스가 쉽게 반증 가능하도록 (= 테스트하기 쉽도록)

만들기 위해 분주히 노력해야 한다.

이를 위해 구조적 프로그래밍과 유사한 제한적인 규칙들을 받아들여 활용해야 한다.


2) 객체지향 프로그래밍

좋은 아키텍처를 만드는 일은

객체지향 설계원칙을 이해하고 응용하는 데서 출발한다.

저자는 이 객체지향의 본질을 설명하기 위해 아래 개념들을 설명하고,

사람들은 객체지향이 "캡슐화, 상속, 다형성" 세 가지 개념을 적절히 조합한 것이거나,

또는 객체지향 언어는 최소한 세 가지 요소를 반드시 지원해야 한다고 말한다.

  • 캡슐화

객체지향언어는 데이터와 함수를 효과적으로 캡슐화하는 방법을 제공한다.

데이터와 함수가 응집력 있데 구성된 집단을 서로 구분 짓는 선을 그을 수 있다.

구분선 바깥에서는 데이터는 은닉되고, 일부 함수만이 외부에 노출된다.

이 개념들은 실제 객체지향언어에서는

각 클래스의 private 멤버 데이터와 public 멤버 함수로 표현된다.

즉 클라이언트에서는 클래스에서 public으로 제공하는 함수를 호출할 수는 있지만,

클래스 내부의 데이터 구조와 함수가 어떻게 구현되었는지는

조금도 알지 못한다.

  • 상속

상속이랑 단순히 어떤 변수와 함수를 하나의 유효 범위로 묶어서

재정의하는 일에 불과하다.

객체지향언어가 고안되기 훨씬 이전부터 상속과 비슷한 기법이 사용되었다.

하지만, 객체지향언어가 완전히 새로운 개념을 만들지는 못했지만,

데이터 구조에 가면을 씌우는 일을 상당히 편리한 방식으로 제공했다고 볼수 있다.

저자는 위 두 개념가지고는 객체 지향이라는 점수를 주기에는 애매한 상황이라고 말한다.

  • 다형성

저자는 c언어의 getchar(), putchar() 함수를 사용하는 복사프로그램을 예시로 들면서 처음에 다형성을 소개한다.

#include <stdio.h>

void copy() {
  int c;
  while (c=getchar()) != EOF)
    putchar(c);
}

getchar() 함수는 STDIN에서 문자를 읽고,

putchar() 함수는 STDOUT으로 문자를 쓴다.

이러한 getchar(), putchar() 함수는 STDIN, STDOUT의 타입에 의존한다.

즉 getchar(), putchar() 함수는 다형적이라고 할 수 있다.

유닉스 운영체제의 경우,

모든 입출력 드라이버가 다섯가지 표준 함수를 제공하도록 요구한다.

열기(open), 닫기(close), 읽기(read), 쓰기(write), 탐색(seek)

FILE 데이터 구조는 이들 다섯 함수를 가리키는 포인터를 아래과 같이 포함할 것이다.

 

struct FILE {
  void (*open)(char* name, int mode);
  void (*close);
  int(*read);
  void (*write)(char);
  void (*seek)(long index, int mode);
}

 

 

 

 

그리고 콘솔용 입출력 드라이버에서는 이들 함수를 아래와 같이 구현하고,

FILE 데이터 구조를 함수에 대한 주소와 함께 로드할 것이다.

 

#include "file.h"

void open(char* name, int mode) { ... }
void close() {...}
int read() { int c; ..... return c; }
void write(char c) { ... }
void seek(long index, int mode) { ... }

struct FILE console = {
  open, close, read, write, seek
};

 

 

dlwp STDIN을 FILE*로 선언하면,

STDIN은 콘솔 데이터 구조를 가리키므로,

getchar() 함수는 애로아 같은 방식으로 구현될 것이다.

extern struct FILE* STDIN;

int getchar() {
  return STDIN->read();  // STDIN으로 참조되는 FILE 데이터 구조의 read 포인터가 가리키는 함수를 단순히 호출한다.
}

 

 

 

 

 

결국 getchar() 함수는

STDIN으로 함조되는 FILE 데이터 구조의

read 포인터가 가리키는 함수를 단순히 호출할 뿐이다.

이처럼 단순한 기법이 모든 객체지향이 지닌 다형성의 근간이 된다.

함수를 가리키는 포인터를 응용한 것이 다형성이다.

- 다형성이 가진 힘

예를 들어, 입출력 장치가 새로 추가된다고 생각해보자.

만약 입출력 장치가 새로 추가됨에도, 우리는 getchar(), putchar() 함수를 바꿔야할까?

아무런 변경도 필요하지 않다!

심지어 복사 프로그램을 다시 컴파일 할 필요가 없다.

이유는 복사프로그램 코드는

입출력 드라이버 소스 코드에 의존하지 않기 때문이다.

신규 추가된 입출력 드라이버가

FILE에 정의된 다섯가지 표준 함수를 구현한다면,

복사 프로그램에서는 이 입출력 드라이버를 얼마든지 사용할 수 있다.

즉 입출력 드라이버만 바꾸면 되고, 복사 프로그램은 그대로 사용할 수 있다.

다시 말해 입출력 드라이버가 복사프로그램의 플러그인이 된 것이다.

프로그램은 장치 독립적이여야 하며,

프로그램이 다른 장치에서도 동일하게 동작할 수 있도록 만들어야 함을 깨달았다.

플러그인 아키텍처는,

이처럼 입출력 장치 독립성을 지원하기 위해 만들어졌다.

  • 의존성 역전

https://en.wikipedia.org/wiki/Dependency_inversion_principle

다형성을 안전하고 편리하게 적용할수 있는 메커니즘이 등장하기 전,

소프트웨어는 어떤 모습이었을지 상상해보자.

전형적인 코드 호출 트리의 경우,

main 함수가 고수준 함수를 호출하고,

고수준 함수는 다시 중간 수준 함수를 호출하고,

중간 수준 함수는 다시 저수준 함수를 호출한다.

이러한 호출 트리에서

소스 코드 의존성의 방향은

반드시 제어흐름을 따르게 된다.

이처럼 제어흐름은 시스템의 행위에 따라 결정되며,

소스코드 의존성은 제어흐름에 따라 결정된다.

하지만 다형성이 끼어들면,

우리는 소스코드 의존성을 제어흐름과 반대로 설정 할 수 있다.

이것이 우리가 흔히 아는 "의존성 역전(dependency Inversion)" 이다.

I 인터페이스의 추가로 인해 ML1와 I 사이의 소스코드 의존성은 제어흐름과 반대가 됨.

객체 지향언어가 다형성을 안전하고 편리하게 제공한다는 사실은,

소스코드 의존성을 어디에서는 역전시킬 수 있다는 뜻이다.

인터페이스가 없는 기존의 소스 코드 의존성 흐름

인터페이스가 추가되어 소스 코드 의존성이 제어 흐름과 역전된 방향

이러한 소스 코드 의존성은

우리는 소스 코드 사이에 인터페이스를 추가함으로써 방향을 역전시킬 수 있다.

이러한 접근법을 사용하면,

우리는 객체지향 언어로 개발된 소프트웨어 아키텍트는

시스템의 소스 코드 의존성 전부에 대해

방향을 결정할 수 있는 절대적인 권한을 갖는다.

우리는

이 의존성 역전을 해서 무엇을 얻을수 있을까?

컴포너트간 의존관계가 없어지고,

컴포넌트 별로 독립적으로 배포와 개발이 가능한 이점이 있다.

예를 들면,

업무 규칙 컴포넌트, 데이터베이스 컴포넌트, UI 컴포넌트가 있다고 가정해보자.

업무규칙이 데이터베이스와 사용자 인터페이스에 의존하는 대신에,

시스템의 소스 의존성을 반대로 배치하여

데이터베이스와 UI가 업무규칙에 의존하게 만들 수 있다.

즉 UI와 데이터베이스가 업무 규칙의 플러그인이 되는 것이다.

다시 말해 업무 규칙의 소스코드에서는 UI나 데이터베이스를 호출하지 않는다.

따라서 업무 규칙을 UI, 데이터베이스와는 독립적으로 배포할 수 있다.

이들 컴포넌트는 개별적이며 독립적으로 배포가 가능하다.

특정 컴포넌트만 변경되면,

해당 코드가 포함된 컴포넌트만 다시 배포하면 된다.

=> 배포 독립성

시스템 모듈을 독립적으로 배포할 수 있게 되면,

서로 다른 팀에서 각 모듈을 독립적으로 개발할 수 있다.

=> 개발 독립성

* 이 의존성 역전 원칙은

다음장에서 다룰 객체지향의 5대원칙 SOLID 원칙에서

Dependency Inversion에서 좀더 상세히 다룰 예정이다.

  • 결론

객체지향이란

다형성을 이용하여 전체 시스템의 모든 소스 코드 의존성에 대하여

절대적인 제어 권한을 획득할 수 있는 능력이다.

객체지향을 사용하면

아키텍트는 플러그인 아키텍처를 구성할 수 있고,

이를 통해 고수준의 정책을 포함하는 모듈은

저수준의 세부사항을 포함하는 모듈에 대해 독립성을 보장할 수 있다

저수준의 세부사항은

중요도가 낮은 플러그인 모듈로 만들 수 있고,

고수준의 정책을 포함하는 모듈과는

독립적으로 개발하고 배포할 수 있다.


3) 함수형 프로그래밍

함수형 프로그래밍에서 핵심이 되는 기반은

람다 계산법이라고 설명한다.

예시로 자바를 이용한 방법과,

리스프에서 파생한 클로저는 함수형 언어로,

클로저를 이용하여 25까지의 정수 제곱을 출력하는 간단한 프로그램 예시를 보여준다.

for(int i=1 ; i<=25 ; i++) {
  System.out.println(i*i);
}

 

 

(println (take 25 (map (fn [x] (* x x)) range))))

리스프를 잘 몰라도,

중괄호를 기준으로 각각 쪼개서 코드를 분석해볼 수 있다.

해석하기 편하려면, 가장 안쪽의 함수 호출부터 시작하는게 가장 좋다.

  • range 함수는 0부터 끝이 없는 정수 리스트를 항목이 실제로 접근하는 시점이 각각을 반환한다.
  • 반환된 각 정수들 (리스트)은 map 함수로 전달되고, 각 정수에 대한 제곱을 계산하는 익명함수 fx [x]를 호출하여,

모든 정수의 제곱에 대해 끝이 없는 리스트를 생성한다. (실제로 무한 생성이 아닌, 각각 접근하는 시점에 값이 생성됨)

  • 제곱된 각 정수들 (리스트)은 take 함수로 전달되어, 앞의 25개까지의 항목으로 구성된 새로운 리스트를 반환한다.

자바 프로그램과 리스프 프로그램의 가장 큰 차이는 무엇일까?

바로 프로그램 내에서 가변 변수가 사용되는지의 여부이다.

자바 프로그램에서는 가변 변수를 사용하며,

이러한 가변 변수는 프로그램 실행 중에 언제든 상태가 변할 수 있다. (i)

반면 클로저 프로그램에서는 이러한 가변변수가 전혀 없다.

클로저에서는 x와 같은 변수가 한번 초기화되면 절대 변하지 않는다.

즉 함수형 언어에서는

변수는 절대 변경되지 않는다.

- 불변성과 아키텍처

아키텍트에서 가변성에 대한 이슈는 중요하다.

경합, 조건, 교착상태, 동시 업데이트 같은 부작용들이

모두 가변 변수로 인해 발생하기 때문이다.

만약 어떤 변수도 도중 갱신되지 않는다면,

경합 조건이나 동시 업데이트 문제가 발생하지 않고,

락이 가변적이지 않다면 교착상태도 일어나지 않는다.

즉 다수의 스레드와 프로세스를 사용하는 애플리케이션에서 마주하는 모든 문제는

가변 변수가 없다면 절대 생기지 않는다.

아키텍트라면 동시성 문제에 지대한 관심을 가져야 한다.

  • 가변성의 분리

불변성과 관련하여 가장 주요한 타협중 하나는

애플리케이션 내부 서비스를 가변 컴포넌트와 불변 컴포넌트로 분리하는 일이다.

불변 컴포넌트에서는,

순수하게 함수형 방식으로만 작업이 처리되며,

어떤 가변 변수도 사용되지 않는다.

불변 컴포넌트는 순수 함수형 컴포넌트가 아닌

하 이상의 다른 컴포넌트와 서로 통신할 수 있다.

상태 변경은 컴포넌트를 갖가지 동시성 문제에 노출될 것이므로,

흔히 트랜잭션 메모리와 같은 실천법을 사용하여

동시 업데이트와 경합 조건 문제로부터 가변 변수를 보호한다.

트랜잭션 메모리는 데이터베이스가 디스크의 레코드를 다루는 방식과 동일한 방식으로

메모리 변수를 처리한다.

즉 트랜잭션을 사용하거나, 또는 재시도 기법을 통해 이들 변수를 보호한다.

위의 접근법에 대한 예시로 클로저의 atom 기능을 예시로 든다.

(java에서는 AtomicInteger와 같은 AtomicXXX 클래스가 있는데,

동시성 환경에서 변수가 예기치 않게 동시에 업데이트되는 상황을 방지하기 위해 사용된다.)

(def counter (atom 0)) ; counter를 0으로 초기화한다.
(swap! counter inc)    ; counter를 안전하게 증가시킨다.

 

 

위 클로저 예시 프로그램과 같이,

atom은 가변 변수가 동시에 업데이트되는 상황을 방지해야하기 때문에,

값을 변경하려면 염격한 제약 조건이 따른다.

(값을 변경하려면 swap 함수를 반드시 사용해야 한다.)

즉, 값을 변경하려고 할때, 동시성 체크로직이 내부적으로 더있을 것이다.

AtomicXXX 클래스도

값을 직접 변경할 수 없고,

해당 클래스에서 제공하는 get, set, compareAndSet() 과 같은 메서드를 이용하여

값을 가져오거나 변경해야 한다.

(물론 해당 메서드 안에서 값을 갖고오거나 변경할 때, 동시성 체크에 대한 방어 로직이 제공되어있을 것이다.)

위 클로저 프로그램에서 swap! 함수는 전통적인 compare-and-swap 방식을 사용한다.

compare-and-swap 방식의 과정은 아래와 같이 흘러간다.

  • 먼저 counter의 값을 읽은후 inc 함수로 전달한다.
  • inc 함수가 반환되면 counter 값을 잠기게 되고, inc 함수로 전달했던 값과 비교한다.
  • 이때 두 값이 같다면 inc 함수가 반환된 값이 counter에 저장되고 잠금은 해제된다.
  • 만약 두 값이 다르다면, 잠금을 해제한 후, 이 전략을 처음부터 재시도한다.

(두 값이 다르다는 것은 다른 곳에서 값이 변경되었다는 뜻이기 때문에, 값을 변경시키면 프로그래머가 의도된 결과와 다르게 나타나기 때문)

이외에도 가변성을 방어하고자 하는 여러 기능들을 제공하고 있지만,

여러 변수가 상호 의존하는 상황에서는 동시 업데이트와 교착상태 문제로부터

완벽하게 보호해주지 못한다.

이러한 상황에서 더 정교한 장치를 사용해야하고,

그만큼 가변성으로 야기되는 여러가지 부작용을 막기 위해 프로그램의 복잡도는 올라갈 것이다.

결국 저자가 말하고자 하는 요지는,

애플리케이션을 제대로 구조화 하려면

변수를 변경하는 컴포넌트와 변경하지 않는 컴포넌트를 분리해야 한다는 것이다.

그리고 이렇게 분리하려면

가변 변수들을 보호하는 적절한 수단을 동원해 뒷받침해야 한다.

따라서 가능한 많은 처리를 불변 컴포넌트로 옮겨야 하고,

가변 컴포넌트에서는 가능한 한 많은 코드를 빼내야 한다.

  • 이벤트 소싱

완전한 함수형 애플리케이션을 만들기 위한 방법으로

저자는 이벤트 소싱의 개념도 추가로 설명하고 있다.

고객의 계좌 잔고를 관리하는 은행 애플리케이션을 예시로 들었을 때,

매번 계좌 잔고를 변경하려는 값을 저장하고, 업데이트하는 대신에

트랜잭션 자체를 저장한다는 발상의 전환을 해보자.

누군가 잔고 조회를 요청할 때마다,

계좌 개설 시점부터 발생한 모든 트랜잭션을 단순히 더한다.

이 전략에서는 가변 변수가 하나도 필요가 없다.

(물론 현업에서는 매 조회마다 전체 트랜잭션을 더하는 로직을 한다는것은 현실적으로 불가능할것이다.

타사의 이벤트 소싱 도입 사례 영상을 보다가 특정 시점값 까지의 누적값은 별도의 데이터 저장소에 저장해둔다거나 하는 기법을 본것 같다.)

저자가 강조하고자하는것은 결국,

이벤트 소싱에 깔려있는 기본 발상은

상태가 아닌 트랜잭션을 저장하자는 전략이다.

상태가 필요해지면, 단순히 상태의 시작점부터 모든 트랜잭션을 처리한다.

이러한 이벤트 소싱 전략을 선택하면,

데이터 저장소에서 삭제되거나 변경되는 것이 하나도 없다는 사실이다.

결과적으로 애플리케이션은 CRUD가 아니가 CR만 수행한다.

또한 데이터 저장소에서

변경과 삭제가 일어나지 않으므로

동시 업데이트 또한 일어나지 않는다.


결론

이 장에서 저자가 말하고자 하는 것은

결국 각각의 프로그래밍 패러다임

<구조적 프로그래밍, 객체 지향 프로그래밍, 함수형 프로그래밍>

은 우리에게 어떤 권한이나 능력을 더 주는 기법들이 아닌,

우리에게서 무언가를 앗아간다. 즉 해선 안될 것들에 대한 규칙을 강조한다.

각각의 패러다임에서 우리가 아키텍처 측면에서 지켜야할것들을 소개하였고,

그것들이 지켜지면서 얻을수 있는 장점들을 소개해주는 챕터였다.

또한 소프트웨어는 1946년 초기 최초의 코드를 작성할 때 사용한 소프트웨어 규칙과

지금의 소프트웨어 규칙은 큰 차이가 없다는 것이다.

도구는 달라졌고, 소프트웨어는 변했지만,

소프트웨어의 핵심은 그대로다.

소프트웨어는

순차, 분기, 반복, 참조로 구성되고 그 이상 이하도 아니다.

관련글 더보기

댓글 영역