선발대

[스파르타] Django 심화반 3주차 (완강) 본문

스파르타코딩클럽/강의 정리

[스파르타] Django 심화반 3주차 (완강)

신선한 스타트 2022. 3. 8. 01:07

수업목표

 

  1. pagination, prefetch를 이해한다.
  2. cascade를 이해한다.
  3. unit test와 end to end 테스트의 차이를 말할 수 있다.

 

  01. 좋아요 개수를 세기  

 

좋아요 개수를 세기 & manager

 

## 좋아요 개수 세기
def test_like_count_should_increase(self) -> None:
	# Given
    user = User.objects.create(name="test")
    article = Article.objects.create(title="test_title")
    
    # When
    do_like(user.id, article.id)
    
    # Then
    article = Article.objects.get(id=article.id)
    self.assertEqual(1, article.like_set.count())
  • article에 달려있는 like는 like_set을 통해 접근할 수 있음.
  • 해당 게시글에 달려있는 모든 좋아요의 개수를 알고 싶다면 count() 함수 사용
  • 확인하면 좋아요 개수가 0에서 1로 잘 증가한 것을 확인 가능.
  • like_set은 manager 객체임.
  • like는 foreignkeyarticle을 가리켰기 때문에 articlelike_set이라는 이름의 RelatedManager 생김.
  • manager: model 객체와 데이터베이스를 이어주는 통로 역할
  • objects 통해 쿼리하면 모델 객체 얻을 수 있음. 이것이 장고다.
  • 예시: Like.objects.all() 
  • like_set도 objects와 같은 manager 이므로 objects. 에서 할 수 있던 필터링, 쿼리 등을 동일하게.

 

  02. 좋아요 취소  

 

좋아요 취소

 

  • 자신의 좋아요 삭제할 수 있게 하기
  • 좋아요가 없을 때는 에러가 발생함
## like_service.py
from tabom.models.like import like

def do_like(user_id: int, article_id: int) -> Like:
	return Like.objects.create(user_id=user_id, article_id=article_id)
    
def undo_like(user_id: int, article_id: int) -> None:
	pass:
    
    
## test_like_service.py
# 자신의 좋아요 삭제하기
def test_a_user_can_undo_like(self) -> None:
	# Given
    user = User.objects.create(name="test")
    article = Article.objects.create(title="test_title")
    like = do_like(user_id=user.id, article_id=article.id)
    
    # When
    undo_like(user.id, article.id)
    
    # Then
    with self.assertRaises(Like.DoesNotExist):
    	Like.objects.get(id=like.id)

# 좋아요 없을 때는 에러가 발생함
def test_it_should_raise_exception_when_undo_like_which_does_not_exist(self) -> None:
	# Given
    user = User.objects.create(name="test")
    article = Article.objects.create(title="test_title")
    
    # Expect
    with self.assertRaises(Like.DoesNotExist):
    	undo_like(user.id, article.id)
## like_service.py
# undo_like 구현하기
from tabom.models.like import Like

def do_like(user_id: int, article_id: int) -> Like:
	return Like.objects.create(user_id=user_id, article_id=article_id)
    
def undo_like(user_id: int, article_id: int) -> None:
	like = Like.objects.filter(user_id=user_id, article_id=article_id) # 1개만 있음
    like.delete()

 

queryset에 직접 delete()를 하는 방법

 

  • like가 존재하지 않더라도 error 가 발생하지 않도록 하는 방법도 있음.
## like_service.py
from tabom.models.like import like

def do_like(user_id: int, article_id: int) -> Like:
	return Like.objects.create(user_id=user_id, article_id=article_id)
    
def undo_like(user_id: int, article_id: int) -> None:
	Like.objects.filter(user_id=user_id, article_id=article_id).delete()
  • SELECT 쿼리 없이 바로 DELETE를 하기 때문에 objects.delete()보다 쿼리 한번 아낄 수 있음.
  • 위와 같은 상황에서 테스트 돌리면,
  • test_a_user_can_undo_like()는 성공하지만, DoesNotExist는 발생하지 않아서,
  • test_it_should_raise_exception_when_undo_like_an_does_not_exist()는 실패함.
  • 따라서 실패하는 테스트는 제거하고, queryset의 delete()를 사용할 것임. 쿼리 절약.

 


  03. 게시글 단 건 조회  

 

게시글 단 건 조회

 

  • ariticle 하나를 조회하는 기능 만들어보기
## services/article_service.py
from tabom.models import Article

def get_an_article(article_id: int) -> Article:
	#pass
	return Article.objects.get(id=article_id)    


## tests/test_article_service.py
from django.test import TestCase

from tabom.models.article import Article
from tabom.services.article_service import get_an_article

# 한번 테스트가 끝나면 롤백이 이루어지기 때문에 다른 테스트에 영향 주지 않음.
# 테스트의 isolation
class TestArticleService(TestCase):
	def test_you_can_get_an_article_by_id(self) -> None:
    	# Given
        title = "test_title"
        article = Article.objects.create(title=title)
        
        # When 
        result_article = get_an_article(article.id)
        
        # Then
        self.assertEqual(article.id, result_article.id)
        self.assertEqual(title, result_article.title)
        
    def test_it_should_rasie_exception_when_article_does_not_exist(self) -> None:
    	# Given
        invalid_article_id = 9988
        
        # Exception
        with self.assertRaises(Article.DoesNotExist):
        	get_an_article(invalid_article_id)

 


  04. 게시글 리스트 조회  

 

페이지네이션

 

  • pagination: 전체 리스트를 한 번에 가져오지 않고 쪼개서 가져오는 것
  • sql에서 pagination 할 때 알아야 할 2가지:
  • 1. offset: 몇 번째 요소부터 가져올 것인지. (기본적으로 id 오름차순으로 정렬됨)
  • 2. limit: 몇 개씩 가져올 것인지
  • 만약 offset=0, limit=10, 1번째 요소부터 10개 가져옴.
  • 페이지의 개념은 offset을 구할 때 사용함. (아래 참고)
  • offset: limit * (현재 페이지 - 1)
  • article 생성해보기:
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());
INSERT INTO tabom_article(title, updated_at, created_at) VALUES ('article', NOW(), NOW());

 

게시글 리스트 조회

 

  • 요구사항: 게시글 리스트(최신 글이 맨 위로 오도록)을 보여준다. 
  • 이때 좋아요 받은 개수도 같이 보여준다.
## services/article_service.py
from django.db.models import QuerySet
from tabom.models import Article

def get_an_article(article_id: int) -> Article:
	return Article.objects.get(id=article_id)
    
def get_article_list(offset: int, limit: int) -> QuerySet[Article]:
	# pass
    
    
## tests/test_article_service.py
# 최신 글 리스트 오름차순으로 보여주기. (+ 좋아요 개수도)
def test_get_article_list_should_prefetch_likes(self) -> None:
	# Given
    user = User.objects.create(name="user1")
    articles = [Article.objects.create(title=f"{i}") for i in range(1, 21)]
    Like.objects.create(user_id=user.id, article_id=article[-1].id)
    
    # When
    result_articles = get_article_list(0, 10)
    
    # Then
    self.assertEqual(len(result_articles), 10)
    self.assertEqual(1, result_articles[0].like_set.count())
    self.assertEqual(
    	[a.id for a in reversed(article[10:21])],
        [a.id for a in result_articles]
    )
def get_article_list(offset: int, limit: int) -> QuerySet[Article]:
	return Article.objects.order_by("-id")[offset: offset + limit]
  • 테스트 실행 후에 성공하는 것도 확인

 

페이지네이터 클래스 사용

 

## services/article_service.py
def get_article_page(page: int, limit: int) -> Page[Article]:
	return Paginator(Article.objects.order_by("-id"), limit).page(page)
    
    
## tests/test_article_service.py
def test_get_article_page(self) -> None:
	# Given
    user = User.objects.create(name="test_user")
    articles = [Article.objects.create(title=f"{i}") for i in range(1, 21)]
    do_like(user.id, articles[-1].id)
    
    # When
    result_articles = get_article_page(1, 10)
    
    # Then
    self.assertEqual(len(result_articles), 10)
    self.assertEqual(1, result_articles[0].like_set.count())
    self.assertEqual(
    	[a.id for a in reversed(articles[10:21])],
        [a.id for a in result_articles]
    )
  • 강의에서는 페이지네이터가 아닌 slicing을 사용할 것임.

 


  05. 실제 일어나는 쿼리를 조회하기  

 

실제 일어나는 쿼리 조회하기

 

  • 앞서 만든 테스트에서 모든 article의 좋아요 개수 구하기
## 좋아요 개수의 리스트를 만들 수 있음
# When 
result_articles = get_article_list(0, 10)
result_counts = [a.like_set.count() for a in articles]

# Then
...
self.assertEqual(1, result_counts[0])
  • 코드를 실행할 때 실제 데이터베이스에서 어떤 쿼리가 일어나는지,
  • CaptureQueriesContext를 사용해서 알아보기.
## tests/test_article_service.py
def test_get_article_list_should_prefetch_like(self) -> None:
	# Given
    user = User.objects.create(name="test_user")
    articles = [Article.objects.create(title=f"{i}") for i in range(1, 21)]
    do_like(user.id, articles[-1].id)
    
    with CaptureQueriesContext(connection) as ctx:
    	
        # When
        result_articles = get_article_list(0, 10)
        result_counts = [a.like_set.count() for a in result_articles]
        
        # Then
        self.assertEqual(len(result_articles), 10)
        self.assertEqual(1, result_counts[0])
        self.assertEqual(
        	[a.id for a in reversed(articles[10:21])],
            [a.id for a in result_articles],
        )
  • 공식문서: https://docs.djangoproject.com/en/2.2/_modules/django/test/utils/
  • 중단점 찍고 디버깅함.
  • ctx.captured_quries: 여태까지 실행한 sql이 그대로 들어감. 앞으로도 계속 쓰일 예정.
  • 중단점 찍으면 그 바로 앞까지 진행상황(SQL문)을 evalution에서 확인할 수 있음. 
  • 내부를 보면 각 article별로 좋아요 개수를 세기 위해 쿼리가 실행된 것을 확인할 수 있음.
  • 좋아요 개수 모두 구하기 위해 총 10번이나 쿼리를 한 것. 피해라. 1번의 쿼리로 변경해보자. 
  • 미리 가져오면 쿼리의 개수를 줄일 수 있음.
  • article은 가져왔지만 like는 실제로 count()를 호출하기 전까지 데이터베이스에서 가져오지 않았음.
  • lazy 하다: evaluate 되기 전까지 sql을 실행하지 않는 것
  • eager: lazy의 반대. django에서는 eager loading을 위해 prefetch를 사용함.

 


  06. Prefetch Related  

 

prefetch 사용, assertNumQueries()로 쿼리 횟수 검증하기

 

  • 백엔드의 주요 병목은 데이터베이스 쿼리임.
  • 이번에는 prefetch 사용하고, assertNumQueries()로 실행되는 쿼리 횟수를 검증해보기.
## tests/test_article_service.py
def test_get_article_list_should_prefetch_like(self) -> None:
	# Given
    user = User.objects.create(name="test_user")
    articles = [Article.objects.create(title=f"{i}") for i in range(1, 21)]
    do_like(user.id, articles[-1].id)
    
    # When
    with self.assertNumQueries(2):
    	result_articles = get_article_list(0, 10)
        result_counts = [a.like_set.count() for a in result_articles]
        
        # Then
        self.assertEqual(len(result_articles), 10)
        self.assertEqual(1, result_counts[0])
        self.assertEqual(
        	[a.id for a in reversed(articles[10:21])],
            [a.id for a in result_articles],
        )
  • 지금은 like_set에서 쿼리가 발생하기 때문에 테스트가 실패함.
  • prefetch_related를 사용해보기.
# service/article_service.py
def get_article_list(offset: int, limit: int) -> QuerySet[Article]:
	return Article.objects.order_by("-id).prefetch_related("like_set")[offset: offset + limit]
  • prefetch는 개념 이해할 때만 어렵게 느껴지지 사용법은 쉽다.
  • ./test.sh 를 다시 실행해보면 테스트 끝!

 

Select Related와 prefetch related

 

prefetch_related

 

  • many 관계에서 사용하자. (many관계, OneToOne 관계 모두 사용가능)
  • WHERE IN()절을 사용해서 쿼리 한 번 더 함.
  • 전부 가져오고, 각각의 article에 맞는 좋아요 꽂는 일을 장고가 해준다.
  • article 하나에 좋아요 여러 개.

 

select_related

 

  • OneToOne 혹은 foreign key 관계에서 사용하자. (many에서 쓰면 에러!)
  • Join을 사용하기 때문에 추가 쿼리가 일어나지 않음. (내부 구조상의 문제)

 

Article.objects.prefetch_related("like_set").all()
Article.objects.select_related("like_set").all() # many에서 쓰면 에러!



Like.objects.select_related("user").all() # 쿼리 1번 발생
Like.objects.prefetch_related("user").all() # 에러는 안 나지만 쿼리가 2번 발생

def test_temp(self) -> None:
	# Given
    user = User.objects.create(name="user1")
    article1 = Article.objects.create(title="article1")
    like = Like.objects.create(user_id=user.id, article_id=article.id)
    Article.objects.create(title="article2")
    
    # Except
    with self.assertNumQueries(1):
    	result_like = Like.objects.select_related("user").get(id=like.id)
        self.assertEqual(like.id, result_like.user.id)
        
    # Expect
    with self.assetNumQueries(2):
    	result_like = Like.objects.select_related("user")get(id=like.id)
        self.assertEqual(like.id, result_like.user.id)
  • select_related: join을 사용해서 가져오기 때문에, 쿼리 개수가 증가하지 않음.
  • prefetch_related: prefetch 대상을 가져오기 위해 추가적으로 쿼리를 날림.

 

join을 처음 접하시는 분들께

 

 


  07. 이미 좋아요 했는지 여부를 표시하기  

 

이미 좋아요 했는지 여부를 표시하기

 

  • 요구사항: 이미 좋아요 했는지 여부 표시하기
  • 게시글에 달린 좋아요는 like_set으로 구할 수 있었음
  • 이 중에서 특정 user_id를 가진 like만 가져올 수 있으면 여부 표시할 수 있음.
  • 접속한 사용자의 id가 있다면 그건 좋아요를 한 것임.
  • 내가 한 좋아요도 한 번에 다 가져올 수 있음.
  • 공식문서: https://docs.djangoproject.com/en/4.0/ref/models/querysets/#django.db.models.Prefetch
  • Prefetch object 사용해보기: prefetch 할 때 필터링 할 수 있음.
  • 여기에 keyword argument to_attr 전달하면 조건에 맞는 relation을 해당 필드에 set 해준다.
  • 특정 조건에 맞는(여기에서는 user_id) 것의 특성(my_likes)을 가져오도록 함.
## tests/test_article_service.py
def test_get_article_list_should_contain_my_like_when_like_exists(self) -> None:
	# Given
    # 유저가 있음, 실제 좋아요 받은 경우. my_likes는 1임.
    user = User.objects.create(name="test_user")
    article1 = Article.objects.create(title="article1")
    like = do_like(user.id, article1_id)
    # 유저가 없음 - my_likes는 null임.
    Article.objects.create(title="article2")
    
    # When
    articles = get_article_list(user.id, 0, 10)
    
    # Then
    self.assertEqual(like.id, articles[1].my_likes[0].id)
    self.assertEqual(0, len(articles[0].my_likes))
    
    
## service/article_service.py
def get_article_list(user_id: int, offset: int, limit: int) -> QuerySet[Article]:
	return(
    	Article.objects.order_by("-id")
        .prefetch_related("like_set")
        .prefetch_related(Prefetch("like_set", queryset=Like.objects.filter(user_id=user_id), to_attr="my_likes"))[
        	offset: offset + limit
         ]
         
## service/article.py
from typing import Any, List
from django.db import models
from tabom.models.base_model import BaseModel

class Article(BaseModel):
	title = models.CharField(max_length=255)
    my_likes: List[Any] # Prefetch에서 사용함
  • 쿼리 횟수 검증하던 테스트의 쿼리 횟수를 2~3으로 늘려준다.
  • with self.assertNumQueries(3): # 원래는 2였음.
  • 쿼리횟수를 굳이 늘린 이유: 나중에 prefetch_related("like_set")을 없앨 것이기 때문.
  • articles 안에 살펴보기: evaluate에서 [a for a in articles]

 


  08. 단건 조회에도 prefetch 사용하기  

 

단건 조회에서도 Prefetch 사용하기

 

  • 단건조회
# services/
from django.db.models import Prefetch, QuerySet
from tabom.models import Article, Like

def get_an_article(user_id: int, article_id: int) -> Article:
	return Article.objects.prefetch_related(
    	Prefetch("like_set", queryset=Like.objects.filter(user_id=user_id), to_attr="my_likes")
        ).get(id=article_id)
        
def get_article_list(user_id: int, offset: int, limit: int) -> QuerySet[Article]:
	return (
    	Article.objects.order_by("-id")
        .prefetch_related("like_set")
        .prefetch_related(Prefetch("like_set", queryset=Like.objects.filter(user_id=user_id), to_attr="my_likes"))[
        	offset: offset + limit
            ]
         )

 

test case 추가

## test case 추가
# 추가 테스트 케이스
from django.test import TestCase
from tabom.models import Like, User
from tabom.models.article import Article
from tabom.services.article_service import get_an_article, get_article_list
from tabom.services.like_service import do_like

class TestArticleService(TestCase):
	def test_you_can_get_an_article_by_id(self) -> None:
    	# Given
        title = "test_title"
        article = Article.objects.create(title=title)
        
        # When
        # user.id는 상관없어서 그냥 0 넣었음
        result_article = get_an_article(0, article.id)
        
        # Then
        self.assertEqual(article.id, result_article.id)
        self.assertEqual(title, result_article.title)
        
    def test_it_should_raise_exception_when_article_does_not_exist(self) -> None:
    	# Given
        invalid_article_id = 9988
        
        # Expect
        with self.assertRaises(Article.DoesNotExist):
        	# user.id는 상관없어서 그냥 0 넣었음
        	get_an_article(0, invalid_article_id)
            
    def test_get_article_list_should_prefetch_like(self) -> None:
    	# Given
        user = User.objects.create(name="test_user")
        articles = [Article.object.create(title=f"{i}") for i in range(1, 21)]
        do_like(user.id, articles[-1].id)
        
        # When
        with self.assertNumQueries(3):
        result_articles = get_article_list(user.id, 0, 10)
        result_counts = [a.like_set.count() for a in result_articles]
        
        # Then
        self.assertEqual(len(result_articles), 10)
        self.assertEqual(1, result_counts[0])
        self.assertEqual(
        	[a.id for a in reversed(articles[10:20])],
            [a.id for a in result_articles],
        )
        
	def test_get_article_list_should_contain_my_likes_when_like_exists(self) -> None:
		# Given
        user = User.objects.create(name="test_user")
        article1 = Article.objects.create(title="article1")
        like = do_like(user.id, article1.id)
        Article.objects.create(title="article2")
        
        # When
        articles = get_article_list(user.id, 0, 10)
        
        # Then
        self.assertEqual(like.id, articles[1].my_likes[0].id)
        self.assertEqual(0, len(articles[0].my_likes))
        
    def test_get_article_list_should_not_contain_my_likes_when_user_id_is_zero(self) -> None:
    	# Given
        user = User.objects.create(name="test_user")
        article1 = Article.objects.create(title="article1")
        Like.objects.create(user_id=user.id, article_id=article1.id)
        Article.objects.create(title="article2")
        invalid_user_id = 0
        
        # When
        articles = get_article_list(invalid_user_id, 0, 10)
        
        # Then
        self.assertEqual(0, len(articles[1].my_likes))
        self.assertEqual(0, len(articles[0].my_likes))
  • ./test.sh 실행하기

 


  09. ONDELETE CASCADE  

 

sql의 cascade

CREATE TABLE temp_user (
    id bigint AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(20) NOT NULL
);
CREATE TABLE temp_article (
    id bigint AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL
);
CREATE TABLE temp_like(
    id bigint AUTO_INCREMENT PRIMARY KEY,
    user_id bigint NOT NULL,
    article_id bigint NOT NULL,
    constraint unique_user_article
        unique (user_id, article_id),
    constraint temp_like_article_id_fk_temp_article_id
        foreign key (article_id) references temp_article (id) ON DELETE CASCADE,
    constraint temp_like_user_id_fk_temp_user_id
        foreign key (user_id) references temp_user (id) ON DELETE CASCADE
);
  • foreign key에 ON DELETE CASCADE 옵션이 붙어있는 것을 확인할 수 있음.
  • ON DELETE CASCADE: 부모 테이블 row가 삭제될 경우, 연결된 자식 테이블의 row도 삭제.
  • ON DELETE SET NULL: 필드 nullable 설정 필요. not null 해제하고 저장.

 

django의 cascade

 

## test_cascade.py
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext

from tabom.models import Article, Like, User


class TestCascade(TestCase):
    def test_capture_what_queries_excuted_when_cascade(self) -> None:
        user = User.objects.create(name="user1")
        article = Article.objects.create(title="artice1")
        like = Like.objects.create(user_id=user.id, article_id=article.id)
        with CaptureQueriesContext(connection) as ctx:
            article.delete()
            print(ctx)

 


  10. 게시글 삭제  

 

게시글 삭제 기능

## tests/test_you_can_delete_an_article(self) -> None:
def test_you_can_delete_an_article(self) -> None:
        # Given
        user = User.objects.create(name="user1")
        article = Article.objects.create(title="artice1")
        like = do_like(user.id, article.id)

        # When
        delete_an_article(article.id)
        
        # Then
        self.assertFalse(Article.objects.filter(id=article.id).exists())
        self.assertFalse(Like.objects.filter(id=like.id).exists())
        
     
## services/article_service.py
def delete_an_article(article_id: int) -> None:
    Article.objects.filter(id=article_id).delete()
  • DoesNotExit가 아니라 exists()를 사용해서 삭제가 되었음을 검증했음.
  • 테스트의 검증 용도로는 둘 중 어느 방법을 사용해도 무관함.

 


  11. 게시글 생성  

 

게시글 생성

## tests
def test_you_can_create_an_article(self) -> None:
	# Given
    title = "test_title"
    
    # When
    article = create_an_article(title)
    
    # Then
    self.assertEqual(article.title, title)


## services
def created_an_article(title: str) -> Article:
	return Article.objects.create(title=title)
  • 테스트에서 Article.objects.create() 모두 create_an_article() 호출로 변경함.

 

 

  3주차 숙제  

 

  • 디자인 패턴에 대해 공부하기. 책 "헤드 퍼스트 디자인 패턴" 추천
  • 디자인 패턴은 종류가 엄청 많음.
  • 이 중에서 스트레티지 패턴, 스테이트 패턴, 옵저버 패턴, 싱글턴 패턴은 꼭 배워보기.
Comments