경계를 긋는 언어, 도메인 주도 설계

목차

웹 애플리케이션을 만들다 보면 자연스럽게 아키텍처를 익히게 된다. 레이어드, 클린 아키텍처, 헥사고날처럼 ‘정석’이라 불리는 것들. 그런데 그 정석들은 어쩐지 내 손에 잘 맞지 않았다. 늘 부족하거나, 과하게 느껴졌으니까.

그래서 한동안은 내 방식대로 구조를 만들어봤다. 문제는, 그렇게 짠 구조가 옳은지 그른지 가늠할 명확한 기준이 내겐 없었다는 것이다. 이게 맞나 싶은 찜찜함이 늘 따라다녔다.

‘도메인 주도 설계’라는 말 또한, 줄곧 나와 거리가 먼 이야기로만 여겼다. 여러 팀이 협업하는 대규모 프로젝트나 MSA의 전유물쯤으로 보고 멀찍이 밀어뒀으니까. 그런데 구조를 고민하다 보니, 나는 결국 이 단어 앞으로 되돌아와야 했다.

프로젝트의 구조는 요구사항이 바뀌면 그에 맞춰 변할 수 있어야 한다. 그리고 그 요구사항을 어떤 구조와 코드로 옮길지 파고들다 보면, 결국 도메인 주도 설계와 마주할 수밖에 없었다.

도메인이라는 말의 무게

우리는 도메인이라는 말을 꽤 느낌적으로 쓴다. 요구사항이나 기능에 대한 이야기가 오갈 때 “이 도메인은—” 하고 운을 떼는 식이다. 그러니 우리가 도메인이라 부르는 것의 정체부터 짚고 가야겠다. 도메인은 본래 우리가 만들고자 하는 프로젝트 그 자체를 가리킨다. 더 정확히는, 현실 세계에서 해결하려는 문제의 영역이다.

여기엔 내 주관적인 생각이 하나 있다. 무엇을 도메인이라 부를지는 결국 그것을 어느 관점에서 바라보느냐에 달려 있다는 것이다. 내 프로젝트가 도메인인 이유는, 그 프로젝트 자체가 내가 풀려는 문제의 영역이기 때문이다. 그런데 관점을 바꾸면 이야기가 달라진다. 내 프로젝트에서는 작은 기능 하나에 불과한 것이, 그 기능의 가치에 집중해 파고드는 어떤 회사에는 사업 전체, 곧 그들의 도메인일 수 있다. 이를테면 나에게 ‘결제’는 그저 곁들이는 기능이지만, 결제 대행사에게 결제는 도메인 그 자체다.

그렇다면 우리가 프로젝트 안에서 “주문 도메인”, “상품 도메인”이라 부르는 것들은 사실 도메인이라기보다 하위 도메인에 가깝다. 물론 그걸 도메인이라 불렀다고 틀린 건 아니다. 『도메인 주도 설계 첫걸음』의 저자 블라드 코노노프(Vlad Khononov)도 용어에 대해 비슷한 고백을 한다.

핵심 하위 도메인은 핵심 도메인이라고도 부른다. 예를 들어 에릭 에반스의 『도메인 주도 설계』에서는 ‘핵심 하위 도메인’과 ‘핵심 도메인’을 같은 의미로 사용했다. ‘핵심 도메인’이라는 용어가 자주 쓰이지만, 나는 여러 이유로 ‘핵심 하위 도메인’이라는 표현을 더 선호한다. 핵심 도메인이 실제로는 하위 도메인이며, 비즈니스 도메인과의 혼동을 피할 수 있기 때문이다.

Note

도메인 주도 설계는 보통 “비즈니스 도메인”이라는 용어를 쓴다. 이 방법론 자체가 기업에서 가치를 만들어내는 영역을 다루기 때문이다. 다만 이 글에서는 가치 창출과 직접 이어지지 않는 일반적인 문제 해결 영역까지 함께 다루다 보니, “비즈니스”를 붙이는 게 어색해 그냥 “도메인”이라 적었다.

하위 도메인, 무엇이 핵심인가

하나의 도메인을 완성하려면 결국 여러 개의 하위 도메인(subdomain)을 운영해야 한다. 하위 도메인은 도메인을 세분화한 영역이다. 식별하는 방법은 여럿이지만, 기본적으로는 핵심(Core)·일반(Generic)·지원(Support) 세 가지로 나눈다.

하위 도메인경쟁 우위복잡성변동성구현 방식문제의 성격
핵심(Core)높음높음사내 개발흥미로움
일반(Generic)아니오높음낮음구매/도입해결됨
지원(Support)아니오낮음낮음사내 개발/하청뻔함

솔직히 말하면, 이 표는 처음엔 잘 와닿지 않았다. 도메인 주도 설계가 주로 기업의 비즈니스 도메인을 다루다 보니 “경쟁 우위” 같은 기준은 내 작은 프로젝트에 대보기가 참 애매했다. 그래서 나는 조금 더 느슨한 기준을 함께 쓴다. 이름값이 크거나, 복잡도가 높거나, 다른 하위 도메인들을 끌어다 쓰며 비즈니스 흐름을 주도하는 영역을 핵심으로 보는 식이다. 핵심은 대개 지원·일반 하위 도메인을 참조하면서 그 위에서 동작하기 마련이다.

쇼핑몰을 예로 들면, 주문과 상품 카탈로그처럼 그 서비스의 정체성을 이루는 영역이 핵심이다. 핵심이 꼭 하나여야 하는 것도 아니다. 주문과 상품을 각각 핵심으로 볼 수 있고, 한 도메인 안에 여러 핵심이 공존하는 건 흔한 일이다.

반면 인증이나 결제, 파일 저장 같은 것들은 구현 난이도는 높아도 대부분 검증된 SaaS와 오픈소스가 존재한다. 직접 만들기보다 검증된 라이브러리나 PG사의 인터페이스를 가져다 쓰는 편이 낫다. 이런 영역은 일반 하위 도메인이다. 공지사항이나 회원 기본정보 관리처럼, 핵심보다 단순하지만 핵심이 돌아가기 위해 반드시 필요한 영역은 지원 하위 도메인으로 둔다.

여기까지는 분류의 이야기다. 진짜 골치 아픈 문제는 그다음에 찾아왔다.

같은 이름, 다른 얼굴

이번에 빌려올 개념은 바운디드 컨텍스트(Bounded Context) 다. 직역하면 ‘경계 지어진 맥락’, 어떤 모델과 단어가 통용되는 맥락에 명시적으로 울타리를 친다는 뜻이다. 이게 왜 필요한지는, 하위 도메인을 나눈 직후 마주치는 한 가지 불편한 진실에서 출발한다. 같은 단어가 영역마다 다른 의미를 가진다는 것이다.

쇼핑몰에서 ‘상품’은 어디서나 같은 상품일까. 카탈로그 영역에서 상품은 사진과 설명, 추천 태그를 가진 전시용 정보다. 주문 영역에서 상품은 구매하던 그 순간의 이름과 가격이 박제된 스냅샷이다. 나중에 판매가가 올라도 이미 산 주문의 금액이 따라 바뀌면 안 되니까. 재고 영역에서 상품은 그저 창고에 쌓인 수량일 뿐이다. 현실의 같은 물건을 가리키지만, 각 영역이 관심을 두는 속성은 전혀 다르다.

하나의 상품

카탈로그 컨텍스트

전시용 정보

주문 컨텍스트

구매 시점의 스냅샷

재고 컨텍스트

창고의 수량

만약 이 셋을 하나의 거대한 Product 모델로 합치면 어떻게 될까. 전시용 마케팅 문구와, 주문 시점의 가격 스냅샷과, 실시간 재고 수량이 한 클래스에 뒤섞인다. 어느 영역의 요구사항이 바뀌어도 이 비대한 모델 전체가 흔들린다. 경계가 흐려진 코드의 전형이다.

바로 이 지점에서 바운디드 컨텍스트가 필요해진다. 울타리 안에서 하나의 모델은 단 하나의 의미를 갖고, 경계를 넘어가면 같은 이름이라도 다른 모델이 된다. 이렇게 경계 안에서 통용되는 일관된 언어를 도메인 주도 설계에서는 유비쿼터스 언어(Ubiquitous Language) 라 부른다.

여기서 내가 오래 했던 오해가 하나 있다. 도메인 주도 설계를 겉핥기로만 알던 시절, 나는 이것을 MSA와 같은 선상에 두고 ‘MSA를 하기 위한 방법론’ 쯤으로 여겼다. 그러니 바운디드 컨텍스트도 곧바로 MSA의 서비스 경계와 같은 것이라 생각했다. 컨텍스트를 나눈다는 건 곧 서비스를 쪼개는 일이라고. 하지만 도메인 주도 설계는 MSA가 아니다. 바운디드 컨텍스트는 어디까지나 모델을 나누는 개념적인 경계일 뿐, 서비스를 물리적으로 분리하라는 지시가 아니다.

하나의 모놀리식 안에서 경계를 긋는 일이 잘못이라고는 생각하지 않는다. 규모가 크든 작든, 맥락에 따라 쓰는 언어, 즉 같은 단어의 의미가 달라지는 지점이라면 그곳이 바운디드 컨텍스트를 나누는 기준이 될 수 있으니까. 그래서 나는 도메인 주도 설계를 통째로 받아들이기보다, 그 일부 개념을 내 프로젝트에 필요한 만큼만 녹여 쓰는 편이 효율적이라고 생각하게 되었다.

또 하나 깨달은 것은, 애플리케이션 아키텍처와 데이터 아키텍처는 다른 층위라는 점이다. 모델이 컨텍스트마다 다른 의미를 갖는다고 해서, DB의 테이블 구조까지 그 경계를 그대로 따라가야 하는 건 아니다. 코드에서는 컨텍스트별로 ‘상품’을 따로 정의하더라도, 그 뒤의 데이터는 하나의 product 테이블에서 함께 출발해도 된다. 경계는 코드 안에서 모델이 의미를 갖는 방식의 문제이지, 곧장 물리적 저장 구조의 문제가 아니었다.

Note

바운디드 컨텍스트와 하위 도메인이 항상 1:1로 떨어지는 것도 아니다. 하위 도메인은문제 공간(무엇을 해결할 것인가)의 분류이고, 바운디드 컨텍스트는 해결 공간(어떻게 모델링할 것인가)의 경계다. 다만 내가 다루는 모놀리식 환경에서는 이 둘을 대체로 일치시키는 편이 관리하기 편했다.

모놀리식에서는 이 경계가 물리적으로 강제되지 않는다는 점이 오히려 함정이었다. 마음만 먹으면 어느 테이블이든 조인할 수 있고, 어느 모델이든 직접 참조할 수 있다. 그래서 경계는 내가 의식적으로 그어야 하는 선이 된다. 누가 막아주지 않는다. 이 선을 코드로 드러내는 방법은 여러 가지인데, 그 이야기는 다른 글로 미뤄두겠다.

그릇인가, 모델인가

경계를 긋는 이야기를 했으니, 정작 그 경계 안에 무엇이 담기는지를 볼 차례다. 그 알맹이가 바로 도메인 모델이다.

도메인 모델은 한마디로 현실의 도메인을 코드로 옮긴 모델이다. 우리가 다루는 개념(상품, 주문, 게시글)과 그것들이 지켜야 할 규칙을, 데이터베이스 테이블이 아니라 코드 안의 객체로 표현한 것. 엔티티와 값 객체 같은 것들이 그 구성원이다.

한때 나는 도메인 모델이 곧 엔티티를 의미하는 줄 알았다. 하지만 도메인 모델은 엔티티를 말하는 게 아니다. 엔티티는 도메인 모델을 이루는 여러 구성 요소 중 하나일 뿐이다. 포함 관계로 보면 엔티티 ⊂ 도메인 모델이다.

구성 요소한 줄 정의
엔티티(Entity)식별자(ID)로 구분되고 생명주기를 갖는 객체
값 객체(Value Object)식별자 없이 값 자체로 동등성이 결정되는 객체
애그리거트(Aggregate)함께 일관성을 지켜야 하는 엔티티·값 객체의 묶음
도메인 서비스특정 엔티티에 넣기 애매한 도메인 로직

이 혼동에는 이유가 있었다. 백엔드, 특히 내가 쓰던 NestJS와 TypeORM 환경에서는 Entity라는 단어가 두 가지 뜻으로 겹쳐 쓰이기 때문이다. 하나는 도메인 주도 설계가 말하는 엔티티, 곧 정체성과 행동과 규칙을 가진 도메인 개념이다. 다른 하나는 ORM의 엔티티, 즉 @Entity 데코레이터가 붙어 DB 테이블에 매핑되는 클래스다. 모델다운 클래스가 @Entity 하나뿐이다 보니, 나는 자연스럽게 “이 @Entity가 곧 내 도메인 모델”이라 여기고 있었다. 하지만 엄밀히 말하면 @Entity영속성 모델이다. “DB에 어떻게 저장되는가”를 표현하지, “이 도메인이 어떤 규칙을 갖는가”를 표현하는 게 아니다.

그 차이를 가장 선명하게 느낀 건 비즈니스 규칙을 어디에 둘 것인가를 고민할 때였다. 많은 프로젝트에서 엔티티는 그저 데이터를 담는 그릇이다. getter와 setter만 잔뜩 있고, 정작 “이미 결제된 주문은 취소할 수 없다” 같은 규칙은 전부 서비스 계층에 흩어져 있다. 이런 모델을 빈약한 도메인 모델(Anemic Domain Model) 이라 부른다.

// 빈약한 모델: 규칙이 서비스에 흩어진다
class Order {
  status: string;
  totalAmount: number;
}

class OrderService {
  cancel(order: Order) {
    if (order.status === 'PAID') {
      throw new Error('이미 결제된 주문은 취소할 수 없습니다');
    }
    order.status = 'CANCELED';
  }
}

반대로 규칙을 모델 안으로 끌고 들어오면 풍부한 도메인 모델(Rich Domain Model) 이 된다. 객체가 자신의 상태를 스스로 책임지는 것이다.

// 풍부한 모델: 규칙이 모델 안에서 지켜진다
class Order {
  private status: OrderStatus;
  private totalAmount: Money;

  cancel() {
    if (this.status === OrderStatus.Paid) {
      throw new Error('이미 결제된 주문은 취소할 수 없습니다');
    }
    this.status = OrderStatus.Canceled;
  }
}

차이가 보일 것이다. 풍부한 모델에서는 status를 외부에서 함부로 바꿀 수 없다. 오직 cancel()이라는 의도가 분명한 행동을 통해서만 상태가 바뀐다. 객체가 자기 상태의 문지기가 되어, 깨지면 안 되는 규칙인 불변식을 스스로 지키는 것이다.

Note

방금 나온불변식(invariant) 이라는 말이 생소할 수 있다. 어렵게 들리지만 별게 아니다. 그 모델이 어떤 상태에 있든 절대 깨지면 안 되는 규칙을 뜻한다. “결제된 주문은 취소할 수 없다”, “금액은 음수일 수 없다” 같은 것. 데이터가 바뀔 때마다 이 규칙이 참인지 누군가는 책임지고 지켜야 하는데, 풍부한 모델에서는 그 책임을 객체 자신이 진다.

풍부한 모델을 만들다 보면 자연스럽게 따라오는 개념이 값 객체(Value Object) 다. 위 예시의 Money가 그렇다. 금액은 숫자와 통화가 항상 함께 다녀야 하고, “음수일 수 없다” 같은 규칙을 가진다. 이걸 Money라는 하나의 값 객체로 묶으면 그 규칙이 객체가 생성되는 순간 강제된다. 식별자로 구분되는 엔티티와 달리, 값 객체는 그 값 자체로 동등성이 결정된다. 만 원은 어느 만 원이든 같은 만 원이니까.

Note

그렇다고 “무조건 풍부한 모델이 정답”인 건 아니다. 규칙이 거의 없는 단순 CRUD 영역(공지사항 같은 지원 하위 도메인)까지 굳이 풍부한 모델로 만들 필요는 없다.규칙이 복잡하고 변동이 잦은 핵심 하위 도메인일수록 모델 안에 규칙을 모으는 보상이 커진다. 영역마다 다른 무게를 주는 것, 그게 내가 생각하는 실용주의의 핵심이다.

값 객체를 두고도 비슷한 생각이다. 나는 정말 필요할 때만 값 객체를 만드는 편이 낫다고 본다. 이메일, 금액, 주소처럼 일반적인 값들은 이미 대부분의 검증 라이브러리가 잘 다뤄주는 영역이다. 우리 도메인에만 해당하는 특별한 정책이 얹히는 게 아니라면, 굳이 직접 값 객체로 감쌀 이유가 없다. 검증된 라이브러리를 쓰면 될 일이다. 모든 것을 순수하게 직접 빚겠다고 들면, 결국 손이 많이 가고 관리하기 버거운 코드 덩어리만 남을 수도 있다.

도메인 영역은 순수해야 한다는 말, 나는 이게 절반만 맞다고 생각한다. 나는 클린 아키텍처를 떠받드는 순수주의자라기보다 실용주의쪽에 가깝다. 코드를 순수하게 유지하는 것과, 이해타산을 따져 트레이드오프를 결정하는 것은 별개의 영역이다. 도메인 주도 설계 역시 방대한 내용을 담고 있지만, 나는 지금의 내가 이해할 수 있는 범위 안에서, 그리고 중요하다는 건 알지만 경험이 부족해 아직 온전히 체감하지 못하는 영역까지도, 딱 필요한 만큼만 덜어 쓰는 것을 추구한다.

그래서 “엔티티가 곧 도메인 모델인가”라는 처음의 질문에 이제는 이렇게 답한다. 엔티티는 도메인 모델의 한 조각일 뿐, 둘은 같지 않다. 헷갈렸던 건 도메인 모델 전체가 아니라 그 안의 엔티티 자리에서였다. ORM의 @Entity와 도메인 모델이 말하는 엔티티, 이 둘을 굳이 별도의 클래스로 쪼갤 필요는 없다고 본다. 모놀리식이라면, 하나의 @Entity 클래스가 영속성 매핑과 도메인 엔티티의 역할을 함께 맡아도 괜찮다. 순수주의 도메인 주도 설계처럼 도메인 모델과 영속성 모델을 따로 두고 그 사이에 매퍼를 끼우는 건, 내 프로젝트에서는 너무 비싼 선택이었다. 다만 그렇게 겸용할 거라면, 그 클래스를 데이터 그릇으로만 두지 말고 행동과 규칙을 그 안에 담아야 한다. 그래야 그 @Entity가 진짜 ‘도메인 엔티티’라 불릴 자격을 얻는다.

함께 묶여야 하는 것들

도메인 모델을 이루는 엔티티와 값 객체는 외딴 섬이 아니다. 어떤 것들은 늘 함께 묶여서 하나의 단위로 다뤄져야 한다. 그 묶음이 애그리거트(Aggregate) 다. 바운디드 컨텍스트가 모델 사이에 긋는 큰 경계라면, 애그리거트는 그 안에서 데이터를 다루는 더 작은 단위의 경계인 셈이다.

애그리거트에는 외부에서 접근할 수 있는 유일한 입구가 있는데, 그것을 애그리거트 루트(Aggregate Root) 라 부른다. 외부는 루트를 통해서만 내부에 닿을 수 있고, 묶음 안의 객체를 직접 건드릴 수 없다.

이 블로그의 게시글을 떠올려보자. 게시글(Post)은 본문과 함께 여러 개의 첨부파일(Attachment)을 거느린다. 첨부파일은 게시글 없이는 존재할 이유가 없다. 게시글이 사라지면 함께 사라지고, 첨부파일 하나만 따로 떼어 다룰 일도 없다. 그렇다면 첨부파일은 게시글이라는 애그리거트 안에 속하고, Post가 그 루트가 된다.

Post 애그리거트

authorId

postId

Post · 루트

Attachment

User 애그리거트

Comment 애그리거트

반면 댓글(Comment)이나 작성자(User)는 어떨까. 댓글은 그 자체로 독립적인 생명주기를 가진다. 자기만의 작성·수정·삭제 규칙이 있고, 게시글과 별개로 페이징되어 조회된다. 작성자는 말할 것도 없다. 이들은 게시글과 별개의 애그리거트다. 그래서 PostUser를 통째로 안는 대신 authorId만 들고, 댓글은 postId로 게시글을 가리킨다. 애그리거트끼리는 객체 참조가 아니라 ID로 참조한다.

여기서 한참 헷갈렸던 부분이 있다. “게시글이 사라지면 댓글도 안 보이는데, 그럼 한 묶음 아닌가?” 같은 의문이었다. 하지만 함께 사라지느냐 마느냐는 삭제 정책(cascade) 의 문제일 뿐, 같은 애그리거트라는 신호가 아니다. 애그리거트를 가르는 진짜 기준은 “함께 사라지는가” 가 아니라 “하나의 불변식을 함께 지켜야 하는가, 외부가 반드시 루트를 거쳐야만 접근할 수 있는가” 였다. 댓글은 게시글 루트를 거치지 않고도 독립적으로 다뤄지니, 별개의 애그리거트인 것이다.

그렇다고 댓글이 게시글과 다른 바운디드 컨텍스트에 있다는 뜻은 아니다. 애그리거트 경계와 바운디드 컨텍스트 경계는 다른 층위다. 게시글과 댓글은 둘 다 ‘콘텐츠’라는 같은 컨텍스트 안에 있으면서, 그 안에서 서로 다른 애그리거트로 나뉜다. 바운디드 컨텍스트가 동네라면, 애그리거트는 그 안의 집들인 셈이다. 하나의 바운디드 컨텍스트 안에는 이렇게 여러 애그리거트가 함께 산다. PostComment처럼 말이다. 반대로 집 하나가 두 동네에 걸칠 수 없듯, 한 애그리거트가 여러 컨텍스트에 걸치는 일은 없다.

애그리거트를 찾아보다 보면, 그 경계를 구분 짓는 방법 중 하나로 “하나의 트랜잭션은 하나의 애그리거트만 변경한다”는 이야기를 만나게 된다. 처음엔 나도 이걸 지켜야 할 규칙쯤으로 받아들였다. 그런데 이 규칙의 뿌리는 분산 환경에 있었다. MSA에서는 보통 바운디드 컨텍스트(서비스) 단위로 저장소가 분리되고, 애그리거트들은 서로 다른 트랜잭션 경계에 놓이기 쉽다. 그래서 애그리거트 사이의 일관성을 하나의 트랜잭션이 아니라 결과적 일관성(eventual consistency) 으로 맞춘다. 모놀리식에서 이 전제를 그대로 가져오면 오히려 독이 된다.

현실에서는 여러 애그리거트가 한 트랜잭션 안에서 함께 보장되어야 하는 경우가 많다. 예를 들어 어떤 게시판에서 글을 발행할 때, 그 글을 구독하는 사람들에게 보낼 알림 이력을 함께 남긴다고 해보자. 여기서 실제로 알림을 쏘는 외부 기술(이메일이나 푸시 발송)은 트랜잭션으로 묶을 단위가 아니다. 하지만 게시글 생성과 알림 이력 생성은 이야기가 다르다. 분산 환경이 아닌 모놀리식이라면, 이 둘을 하나의 트랜잭션으로 묶지 않을 이유가 없다.

그런데 게시글과 알림 이력은 엄연히 서로 다른 애그리거트다. 결국 “애그리거트 하나가 곧 트랜잭션 하나”라는 말은, 적어도 내 모놀리식 세계에서는 그대로 들어맞기 어려운 셈이다.

내가 빌려온 것들

긴 이야기를 돌아왔다. 솔직히 나는 아직 도메인 주도 설계를 잘 안다고 말할 자신이 없다. 전략적 설계니 전술적 패턴이니 하는 방대한 체계를 다 소화하지도 못했고, 앞으로도 직접 경험하지 못한 것들은 이론만으로는 온전히 이해하지못할지도 모른다.

다만 이 개념들을 거치며 분명해진 것이 하나 있다. 도메인 주도 설계는 내게 거창한 정답이 아니라 경계를 긋는 언어로 남았다는 점이다. 바운디드 컨텍스트는 모델이 일관된 의미를 갖는 경계를 긋게 했고, 도메인 모델은 그 경계 안을 실제 코드와 규칙으로 채우게 했으며, 애그리거트는 그중 함께 묶여야 할 것들의 일관성 단위를 정하게 했다. 결국 모두 “무엇과 무엇 사이에 선을 그을 것인가, 그리고 그 안에 규칙을 어떻게 담을 것인가”에 대한 이야기였다.

한때 나는 이 단어들을 느낌으로만 말했다. 지금도 능숙하다고는 못 하겠다. 그래도 이제는 누군가 “그래서 도메인이 뭔데?”라고 물어도 어렵지 않게 답할 수 있을 것 같다. 전부를 욕심내는 대신 내 작은 프로젝트에 필요한 만큼만 빌려 쓰는 것. 지금은 그걸로 충분하다.

끝까지 읽어주셔서 감사합니다

다른 글도 둘러보세요

목록으로 돌아가기