프로그래머라면 다음과 같은 코드를 만들어야한다.
"Clean code that works!"
TDD(Test Driven Development)는 이것을 추구하는 가장 현실적인 방법이다.
수많은 하드웨어와 소프트웨어, 언어와 개발방법론, 프레임워크 등 끊임없이 쏟아져 나오는 IT업계의 신기술은 이 시대의 프로그래머에게 마냥 좋은 소식만은 아닌 것 같다. 프로그래머란 변화를 수용하지 않고는 가까운 미래마저 보장받기 어려운 직업이기 때문이다. 항상 새로운 것을 배우고 개척해야만 뒤쳐지지 않을 것이라는 부담이 우리를 억누르고 있고, 또한 일정한 나이가 지나면 지금껏 쌓아온 지식과 경험이 대부분 쓸모없는 것이 되어 버릴 것이라는 불안감이 늘 함께한다. 필자의 주관적인 느낌이지만, 여러분이 프로그래머라면 공감하는 부분이 분명 있을 것이다.
왜 TDD인가?
필자는 또 하나의 그저 그런 복잡하고 귀찮은 개발 방법론을 소개하려는 것이 아니다. 위와 같이 급변하는 IT환경에서도 변하지 않고 끊임없이 효력을 발휘할 수 있는 "기민한 코드를 만드는 스타일"에 대해서 이 기사를 통해 얘기하려고 하는 것이다. 아마 경험이 많은 프로그래머라면 "TDD"의 명성에 대해서 한번쯤 들어보았을 것이다. TDD는 Test Driven Development의 줄임말로 한국어로 번역하면 "테스트 주도적 개발"이 된다. 말 그대로 테스트 주도적으로 프로그램을 개발 하는 방법이다.
필자가 TDD를 처음 접하고 느낀 첫 느낌은 "뭐, 이런 게 다 있어? 테스트를 먼저하고 실제 코드를 작성하라니! 웃기는 이야기군!"이었다. "도대체 실제코드가 없는데 무얼 테스트한다는 말인가!" 하지만 TDD의 진가를 조금 씩 알게 될수록 신세계를 발견하는 것 같은 경이로움을 느꼈다.
"세상에, 이런 식으로 코딩을 한다면 난 정말 두 다리 뻗고 잠을 잘 수 있을 거야!"
필자는 이 기사를 통해 필자에게 큰 깨달음을 주었던 TDD에 대해서 여러분과 함께 생각해 보고 여러분이 그것을 "체험"할 수 있기를 무척이나 희망한다. TDD가 필자에게 주었던 그 "즐거움과 성취감"을 여러분이 조금이라도 느껴볼 수 있다면, 필자는 무한한 보람을 느낄 것이다.
TDD 자세히 알기
전통적으로 우리는 프로그램 개발이 완료된 후에 테스트를 진행한다. 하지만 TDD는 테스트코드를 먼저 작성하고 그 테스트코드를 통과하는 실제코드를 나중에 만든다. 건물을 지을 때 벽돌을 쌓는 방법을 떠올려보자. 벽돌을 쌓을 때는 벽돌을 얼마만큼 쌓을 건지 특정영역에 실로 표시를 해 놓고 벽돌을 쌓다가 실까지 벽돌이 채워지면 쌓는 것을 중지한다.
TDD로 비유하면 공간에 실로 영역을 표시하는 것을 "테스트코드"에, 실제 벽돌을 쌓는 것은 "실제코드"에 비유할 수 있다. 벽돌을 쌓을 때 벽돌이 비뚤어지는지 정확히 쌓이는지 실에 의해서 판단이 가능한 것과 같은 이치로 테스트 코드는 실제 코드가 나아가야 할 방향을 알려주고 있는 것이다.
만약 벽돌이 조금 비뚤어지게 쌓였다면 반듯하게 다시 잡아가게 되는데 이것은 리팩토링에 비유할 수 있겠다. (리팩토링은 소스코드의 기능은 유지한 채로 소스코드의 디자인을 개선해 나가는 방법이다)
TDD의 흐름
TDD에 절대적인 방법이 있는 것은 아니지만, 일반적인 흐름은 있다. 그 흐름은 다음과 같다.
- 무엇을 테스트할 것인가 생각한다.
- 실패하는 테스트를 작성한다.
- 테스트를 통과하는 코드를 작성한다.
- 코드를 리팩토링한다. (테스트코드 또한 리팩토링한다)
- 구현해야 할 것이 있을 때까지 위의 작업을 반복한다.
TDD의 목표
Simple Code는 TDD의 궁극적인 목표이다. 켄트벡(Kent Beck)1, 워드 커닝험(Ward Cunningham)과 함께 익스트림 프로그래밍(Extreme Programming)의 아버지라 불리는 론 제프리즈(Ron Jeffries)는 Simple Code를 다음과 같이 표현하였다.
Clean code that works! (작동하는 깨끗한 코드!)
Simple Code는 코드가 단순하다는 의미가 아니라 중복이 없고 누가 봐도 명확한 코드를 말한다. 가끔 프로젝트를 하다보면 정말 뛰어난 프로그래머들을 보게 된다. "오~ 이것을 이렇게 간단하고 이해하기 쉽게 표현하다니!"라는 감탄사와 함께 말이다. 직관력이 뛰어난 프로그래머는 복잡하게 할 것을 아주 간단하게 만들어버리는 능력이 있다. 하지만 TDD를 이용하면 특별한 능력 없이도 자연스럽게 가장 Simple한 코드를 만들어 나가게 된다.
직관력, 또는 갑자기 떠오르는 아이디어가 프로그램을 훌륭하게 만들 수는 있지만 항상 그러리란 보장은 없다. TDD는 그것을 추구해 나가는 가장 현실적인 방법이다.
어디서부터 시작할 것인가?
TDD가 무엇인지 확실하게 아는 방법은 단 한 가지, 실제로 TDD를 해 보는 것이다. 물론 이 기사의 코드를 직접 작성하고 따라해 보는 것이 가장 좋겠지만 여기에서는 눈으로만 읽어도 TDD가 무엇인지 알 수 있을 정도의 예제로 "TDD 맛보기"를 해 보려고 한다.
다음과 같은 두 날짜(YYYYMMDD)의 일 수 차이를 구하는 프로그램을 TDD로 작성해 보자.
20070515 sub 20070501 = 14
20070501 sub 20070515 = 14
20070301 sub 20070515 = 31 + 30
이 문제는 아주 쉬워 보이지만 만만하지는 않을 듯하다. 일단 가장 걸림돌이 될 만한 것은 윤달에 대한 것이다. 28일도 되었다가 29일도 되는 아주 특이한 것이므로. 잠시 생각해 본 결과 이 문제를 풀기 위한 가장 쉬운 해법은 다음과 같은 것이었다.
결과 = 절대값(첫 번째 날짜의 총 일수 - 두 번째 날짜의 총 일수)
그렇다, 해당 날짜의 0년부터 그 날짜의 지나온 총 일수를 구한다면 문제가 쉽게 해결될 듯하다.
TODO 리스트
- 두 날짜(YYYYMMDD)의 차이일자를 구한다.
- 특정일자의 총 일수를 구한다.
위처럼 TODO리스트에 테스트해야 할 것을 적는 것부터 시작하도록 하자. 만약 테스트가 통과 된다면 이런 식으로 줄을 긋도록 하자. 조금 더 세분화 하여 특정일자의 총 일수를 구하기 위해서 다음의 세 가지 항목을 추가하였다.
- 전년도까지의 총 일수를 구한다.
- 전월까지의 총 일수를 구한다.
- 해당일자까지의 총 일수를 구한다.
즉, 20070515라는 날짜의 총 일수를 구하고 싶다면
2007년 5월 15일의 총 일수 = 0년부터 2006년까지의 총 일수 + 2007년 1월부터 4월까지의 총 일수 + 15
가 된다는 생각이다.
TDD Start!
자, 이제 우리는 다음과 같이 테스트해야 할 목록들을 가지게 되었고 테스트 코드를 작성할 준비가 되었다.
- 두 날짜(YYYYMMDD)의 차이일자를 구한다.
- 특정일자의 총 일 수를 구한다.
- > 전년도까지의 총 일 수를 구한다.
- 전달까지의 총 일 수를 구한다.
- 해당일자까지의 총 일 수를 구한다.
(">"표시는 앞으로 진행 하려고 하는 테스트를 뜻한다.)
위의 TODO리스트를 기반으로 TDD를 시작해 보자. TODO 리스트 중 전년도까지의 총 일 수를 구하는 것을 먼저 테스트해 보기로 하자. 어떻게 구현할 것인가 보다는 어떻게 테스트를 할 것인지를 생각해야 한다.
Junit
잠깐! TDD를 진행하기 전에 junit에 대해 잠시 알아두도록 하자. Junit을 이용한 테스트 코드의 기본 골격은 다음과 같다.
import junit.framework.TestCase;
public class SubDateTest extends TestCase {
public static void main(String[] args) {
junit.textui.TestRunner.run(SubDateTest.class);
}
public void test1() {
...
}
}
junit framework를 이용하기 위해서 알아두어야 할 규칙은 아래와 같다.
- 테스트 코드는 위처럼 junit 프레임워크에 포함되어있는 TestCase라는 클래스를 extends하여 작성한다.
- 위 샘플코드의 "test1"메써드명처럼 "test"로 시작하는 메써드는 자동으로 실행이 된다.
우리는 앞으로 테스트코드를 작성하기 위해서 TestCase에 있는 assertEquals, assertFalse, assertTrue라는 세 개의 메써드를 이용할 것이다. 각각의 사용법은 다음과 같다.
- assertEquals(a, b) - a와 b가 같은지를 조사한다.
- assertTrue(a) - a가 참인지 조사한다.
- assertFalse(a) - a가 거짓인지를 조사한다.
테스트 코드로 실제 코드를 디자인한다.
첫 번째 테스트 코드는 다음과 같다.
public void testGetYearDay() {
assertEquals(0, SubDate.getYearDay(1));
assertEquals(365, SubDate.getYearDay(2));
}
위 테스트 코드의 의도는 다음과 같다.
- 0년이란 것은 존재하지 않기 때문에 1년까지의 총 일 수는 0이 되어야 한다. (최초 일자를 1년 1월 1일이라고 나름대로 설정한 것이다.)
- 2년 까지의 총 일 수는 1년 1월 1일부터 2년 1월 1일까지이므로 365일이 될 것이다.
위 테스트 코드를 작성하는 순간 SubDate라는 클래스는 아직 존재하지 않는다. SubDate라는 클래스는 테스트코드에 의해서 만들어진 가상의 클래스이다. 즉, 테스트 코드에 의해서 SubDate라는 클래스가 디자인되고 있는 것이다. 위처럼 테스트 코드를 작성하고 저장하면 컴파일이 되지 않는다. 컴파일이 되기 위한 가장 빠른 방법은 다음과 같았다.
public class SubDate {
public static int getYearDay(int year) {
return 1;
}
}
위와 같이 SubDate라는 클래스를 작성하면 컴파일은 되지만 테스트 코드 실행 시 실패하는 것을 볼 수 있다. 테스트가 실패한다고 낙담할 필요는 없다. 테스트를 실패하게 만드는 것은 TDD의 중요한 과정 중 하나이다. TDD는 "테스트가 실패할 경우에만 실제코드를 작성 한다"는 간단한 법칙을 따른다.
가장 빨리 테스트를 통과해라!
테스트가 실패하므로 이젠 테스트를 통과하는 코드를 작성해야 한다. 테스트가 실패하지 않고 통과하게 하기 위한 가장 빠른 방법은 다음과 같았다.
public class SubDate {
public static int getYearDay(int year) {
if (year==1 ) return 0;
else return 365;
}
}
이쯤 되면 백이면 백 모두 이런 생각을 하게 될 것이다.
1이면 0을 리턴하고 아닌 경우 365를 리턴하다니, 정말 테스트를 통과하기에 급급한 황당한 메써드 아닌가?
그렇다, 테스트를 통과하기 위한 급급한 방법을 찾는 것! 그것 또한 TDD의 당연한 과정이다. 켄트벡(Kent Beck)은 테스트를 재빠르게 통과하기 위해서는 어떤 죄악(?)을 저질러도 상관없다고 말한다. 그 이유는 TDD싸이클에 의해 결국에는 당연한 코드로 변경되기 때문이다.
TDD의 보폭
우리는 getYearDay라는 메써드가 완전하지 않다는 것을 알고 있다. 실패하는 테스트 코드를 작성할 차례인 것이다. 테스트 코드에 5년 미만의 총 일수를 구하는 다음의 테스트를 한 줄 삽입해 보자.
assertEquals(365+365+365+366, SubDate.getYearDay(5));
이것을 통과하기 위해서는 1년부터 4년까지 중 윤년이 있는지 조사하는 로직이 필요하다. 당장 "뚝딱" 윤년 체크 로직을 만들 수 없으므로 위의 한 줄을 잠시 주석처리하고 윤년인지 아닌지를 검사하는 테스트 코드를 작성하기로 한다.
//assertEquals(365+365+365+366, SubDate.getYearDay(5));
테스트 코드를 작성할 때 중요한 사항은 작성한 테스트를 쉽게 통과할 수 있을지에 대한 판단이다. 만약 테스트를 통과하기 어렵다고 느낀다면 뒤로 미루어 놓거나, 좀 더 쉬운 테스트 코드로 변환을 해야 한다. TDD초보자들은 대부분 테스트 코드가 너무 큰 범위를 다루게 만든다. 이렇게 되면 테스트를 작성하고 어디서부터 시작해야 할지 갈피를 잡지 못하게 된다.
TODO 리스트에 "윤년체크"라는 항목을 추가 하였다.
- > 윤년체크
윤년인지 아닌지를 확인할 수 있는 테스트 코드를 다음과 같이 만들었다.
public void testLeapYear() {
assertTrue(SubDate.isLeapYear(0));
assertFalse(SubDate.isLeapYear(1));
assertTrue(SubDate.isLeapYear(4));
}
4로 나누어 떨어지는 년도가 윤년이라고 익히 알고 있었기에 위와 같은 테스트가 만들어질 수 있었다. 테스트를 통과하기 위한 가장 빠른 실제코드는 다음과 같았다.
public static boolean isLeapYear(int year) {
if (year == 0) return true;
if (year == 1) return false;
if (year == 4) return true;
return false;
}
리팩토링
테스트 코드와 실제코드를 잘 살펴보면 "데이터의 중복"을 발견할 수 있다. 그것은 바로 0, 1, 4라는 숫자이다. 이 숫자를 유심히 관찰하면 다음과 같이 리팩토링을 할 수 있다.
public static boolean isLeapYear(int year) {
if (year % 4 == 0) return true;
return false;
}
4라는 중복 숫자가 남아 있긴 하지만 4라는 숫자는 의미가 있는 숫자이므로 일단은 그대로 놔두기로 한다. TDD의 흐름은 지금까지 진행해 온 것처럼 "테스트=>코드=>리팩토링"의 순서로 자연스럽게 진행되고 있음에 주목하자.
테스트 코드로 밝히는 진실
테스트를 계속 하기 전에 먼저 윤년이 무엇인지 개념을 확실히 해 보자.
- 서력 기원 연수가 4로 나누어 떨어지는 해는 우선 윤년으로 하고,
- 그 중에서 100으로 나누어 떨어지는 해는 평년으로 하며,
- 다만 400으로 나누어 떨어지는 해는 다시 윤년으로 정하였다.
즉 해석해 보면, 1200년은 400으로 나누어 떨어지고 100으로도 나누어 떨어지지만 400을 먼저 생각하기 때문에 윤년이다. 700년은 100으로 나누어 떨어지기 때문에 윤년이 아니다. 즉 400, 100, 4라는 우선순위를 적용시켜야 한다는 점이다. 이것을 나타날 낼 수 있는 테스트 코드는 다음과 같다.
public void testLeapYear() {
assertTrue(SubDate.isLeapYear(0));
assertFalse(SubDate.isLeapYear(1));
assertTrue(SubDate.isLeapYear(4));
assertTrue(SubDate.isLeapYear(1200));
assertFalse(SubDate.isLeapYear(700));
}
위 테스트 코드는 테스트 실행 시 assertFalse(SubDate.isLeapYear(700));에서 실패한다. 테스트를 통과하기 위해서는 다음과 같이 실제코드를 수정해야만 한다.
public static boolean isLeapYear(int year) {
if (year % 100 == 0) return false;
if (year % 4 == 0) return true;
return false;
}
실제 코드를 위와 같이 수정하니 이번에는 assertTrue(SubDate.isLeapYear(1200));에서 실패한다. 그래서 실제코드는 다음과 같이 다시 바뀌어야 했다.
public static boolean isLeapYear(int year) {
if (year % 400 == 0) return true;
if (year % 100 == 0) return false;
if (year % 4 == 0) return true;
return false;
}
- 윤년체크
- > 전년도까지의 총 일수를 구한다.
다음 테스트로 넘어가기 전에 한 가지 유념해야 할 것은 "윤년을 체크하는 테스트 코드가 믿음을 주는가?" 이다. 완벽하지 않다고 느낀다면 확신할 수 있는 테스트 코드를 작성해야 한다. 이제 첫 번째 테스트 코드에 주석으로 막아 놓았던 아래의 문장을 수행 해 보자.
assertEquals(365+365+365+366, SubDate.getYearDay(5));
테스트는 실패할 것이다, 우리는 미리 만들어 놓았던 윤년 체크 로직을 이용하여 쉽게 테스트를 통과할 수 있을 것이다.
public static int getYearDay(int year) {
int result = 0;
for (int i=1; i < year; i++) {
if (isLeapYear(i)) result += 366;
else result += 365;
}
return result;
}
전년도까지의 총 일수 구하는 것은 테스트가 통과되었다.
- 전년도까지의 총 일수를 구한다.
- > 전월까지의 총 일수를 구한다.
TDD의 리듬을 느껴보자
이전에 단순히 테스트를 통과하기 급급한 메써드가 "당연한 코드"로 뒤바뀌는 모습에 주목하길 바란다. 테스트 코드에 의해 당연한 실제 코드가 자연스럽게 만들어 지는 것이다. 자, 이젠 전월까지의 총 일수를 구해보도록 하자. 테스트 코드는 다음과 같다.
public void testGetMonthDay() {
assertEquals(0, SubDate.getMonthDay(1));
assertEquals(31, SubDate.getMonthDay(2));
}
테스트 코드를 통과하기 위해 가장 빠르게 작성된 실제 코드는 다음과 같다.
public static int getMonthDay(int month) {
if (month == 1) return 0;
else return 31;
}
그런데 갑자기 다음과 같은 의문이 떠올랐다.
구하려는 일수에 포함되는 2월이 29일까지 있는지, 28일까지 있는지 어떻게 알지?
2월, 즉 윤달에 대한 처리를 어떻게 할 것인가? 하는 점이다. 20070515라는 날짜의 총 일수를 구하려 할 때 다음과 같은 전략을 세웠던 것을 기억해 보자.
2007년 5월 15일의 총 일수 = 0년부터 2006년까지의 총 일수 + 2007년 1월 부터 4월까지의 총 일수 + 15
즉, 2007년 1월부터 4월까지의 총 일수는 31+28(2007년이 윤년이 아니기 때문)+31+30이 되는 것을 알 수 있다. 테스트 코드에 이러한 의도를 담아 다음과 같이 작성할 수 있었다.
public void testGetMonthDay() {
assertEquals(0, SubDate.getMonthDay(1, true));
assertEquals(31, SubDate.getMonthDay(2, false));
}
2월이 윤달인지 아닌지를 두번째 파라미터로 보내겠다는 의도이다. (총 일수를 구하려는 일자의 연도가 윤년인 경우는 true, 윤년이 아닌 경우는 false이다) 컴파일이 되기 위한 실제 코드는 다음과 같이 변해야 한다.
public static int getMonthDay(int month, boolean isLeap) {
if (month == 1) return 0;
else return 31;
}
이제 테스트 코드에 윤달 부분을 테스트할 수 있도록 추가해 보자.
assertEquals(31+28, SubDate.getMonthDay(3, false));
assertEquals(31+29, SubDate.getMonthDay(3, true));
3월 전달까지의 총일수를 윤달이 낀 경우와 아닌경우를 테스트하는 것이다. 테스트를 통과하기 위해서 실제코드는 다음과 같이 변할 것이다.
static final int[] monthDays = {
31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
public static int getMonthDay(int month, boolean isLeap) {
int result = 0;
for(int i=1; i
result += monthDays[i-1];
}
if (isLeap && month > 2) result += 1;
return result;
}
- 전월까지의 총 일수를 구한다.
- > 해당일자까지의 총 일수를 구한다.
자 이젠 전월까지의 총 일수를 구했으므로 해당일자의 총 일수를 구하면 될 것이다. 하지만 해당일자의 총 일수는 일자 값 그 자체이므로 테스트가 필요 없다고 느껴진다.
- 해당일자까지의 총 일수를 구한다.
- > 특정일자의 총 일수를 구한다.
이제 특정일자의 총 일수를 구해보도록 하자.
public void testGetTotalDay() {
assertEquals(1, SubDate.getTotalDay("00010101"));
assertEquals(366, SubDate.getTotalDay("00020101"));
}
1년 1월 1일의 총 일수는 1일이 될 것이고, 2년 1월 1일까지의 총 일수는 366일이 되어야 한다. 테스트를 통과하기 위한 실제코드를 작성하면 다음과 같다.
public static int getTotalDay(String date) {
int year = Integer.parseInt(date.substring(0, 4));
int month = Integer.parseInt(date.substring(4, 6));
int day = Integer.parseInt(date.substring(6, 8));
return getYearDay(year)
+ getMonthDay(month, isLeapYear(year))
+ day;
}
- > 두 날짜(YYYYMMDD)의 차이일자를 구한다.
- 특정일자의 총 일수를 구한다.
자 이제 마지막 두 날짜의 차이 일자를 구하는 우리의 마지막 테스트가 남아 있다. 테스트 코드는 아래와 같다.
public void testSubDate() {
assertEquals(1, SubDate.sub("20061231", "20070101"));
assertEquals(31+28+30+31+14,
SubDate.sub("20070101", "20070515"));
assertEquals(31+29+30+31+14,
SubDate.sub("20080101", "20080515"));
}
테스트를 통과하려면,
public static int sub(String date1, String date2) {
return Math.abs(getTotalDay(date1) - getTotalDay(date2));
}
오류없이 모든 테스트가 통과될 것이다. Congratulations!!
마치며
TDD에 대한 모든 것을 다 설명하기엔 부족한 예제이지만 TDD가 무엇인지 감을 잡을 수 있는 예제였으리라 생각한다. 독자 중에는 "이렇게 쉬운 문제를 뭐 하러 귀찮게 TDD로 해야 하나?"라고 반문할 수도 있겠지만 TDD가 숙달되면 이 정도의 쉬운 문제라도 TDD를 사용하는 것이 더 빠르고 즐거운 길이 된다는 것을 곧 깨닫게 될 것이다. 당연히 프로그램이 복잡해지고 어려워질수록 TDD는 더욱더 큰 힘을 발휘하게 된다.
TDD는 자바 언어를 배우거나 XML-RPC등의 개념을 배우는 것과는 매우 다르다. 지식 기반이 아니라 경험에서 우러나오는 것이기 때문이다. XML-RPC의 개념에 대해서 알게 되면 그것을 바로 써먹을 수 있지만 TDD는 사뭇 다르다. TDD가 무엇을 하는 것인지 알게 되어도 능숙해지려면 꽤나 시간이 걸리기 때문이다. 무협지를 보라. 내공이 하루아침에 쌓이는 경우가 있는가? TDD의 가능성을 본 독자라면 꼭 "용기와 끈기"를 가지고 부단히 노력해 볼 것을 다시한번 당부한다. 마지막으로 다음의 책을 꼭 한번 일독할 것을 추천하며 이 글을 마무리할까 한다.
- Test Driven Development By Example (By Kent Beck)
참고자료
- Test Driven Development By Example (By Kent Beck)
- 한국 XP 사용자 모임, http://xper.org
-
Extreme Programming 창안자 중 한 사람, 사람들은 그를 프로그래밍의 달인이라고 부른다. ↩
출처 : https://wikidocs.net/224
'Programming > Java' 카테고리의 다른 글
spring boot 에서 db - autoreconnect 설정 추가 (0) | 2016.01.15 |
---|---|
springBoot / jpa / hibernate 에서 엔티티의 테이블명, 컬럼명 오류 (0) | 2016.01.13 |
F. I.R.S.T - 깨끗한 테스트는 다음 다섯 가지 규칙을 따른다 (0) | 2015.06.13 |
[redirect, forward] java controller method에서 다른 ?.do 를 실행하는 방법 (2) | 2015.06.11 |
POI 엑셀(xlsx 파일) 업로드 시 에러 (1) | 2012.09.27 |
댓글