동적 SQL과 간편 삽입
고정 SQL 절과 이름 기반 값을 한 분기에서 조립하고, 리터럴 LIKE·키셋 조건·SimpleJdbcInsert의 좁은 책임을 실행 코드로 고정합니다.
동적 SQL은 사용자가 보낸 문자열을 SQL 구조에 이어 붙이는 기술이 아닙니다. 서버가 가진 고정된 절만 고르고, 사용자의 값은 모두 이름 기반 파라미터로 보냅니다.
고정 절과 이름 기반 값이 하나의 실행 가능한 쿼리로 합쳐진다
검색 조건이 고정 SQL 절과 값 맵으로 분리된 뒤 BuiltQuery에 합류해 실행되므로 구성 상태가 달라진다.
DYNAMIC SQL · STRUCTURE + BOUND VALUES
동적 SQL은 고정 절과 바인딩 값의 조합이다
서버가 고른 SQL 절과 그 값을 같은 분기에서 추가합니다. 검색어가 SQL 구조나 정렬 방식을 바꾸지 않습니다.
검색 · PostSearch → BuiltQuery → 실행
- 항상 적용할 절
member_id, 포함 날짜 범위, size + 1 제한을 추가합니다.
- 필요한 절과 값을 함께 추가
keyword는 리터럴 LIKE, minimumCharacters는 길이 조건, after는 게시일·ID 배타 커서 조건입니다.
- 고정 순서로 실행
BuiltQuery의 SQL과 이름 기반 값을 전달합니다. 정렬은 published_on DESC, id DESC로 고정합니다.
삽입 · 명령 → 일곱 쓰기 열 → ID
- 어댑터 내부 보조 도구
SimpleJdbcInsert는 열 이름 반복을 줄이는 예제입니다. 별도 애플리케이션 포트가 아닙니다.
- 쓰기 열을 명시
다섯 명령 필드와 생성 시각, 초기 버전만 매핑합니다. 생성 키 열은 id입니다.
- 양수 ID 하나만 허용
키 누락과 비양수 키는 실패입니다. 실제 리포지토리가 저장 뒤 스냅샷 확인까지 담당합니다.
검색어의 %, _, 역슬래시는 데이터로 이스케이프합니다. 절 선택과 값 바인딩은 다른 책임입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 여섯 노드와 여섯 간선이다. 검증된 검색 조건은 필요한 고정 절을 선택하고 별도의 이름 기반 값 맵을 만든다. 절 선택→SQL 구조와 SQL 구조·값 맵→BuiltQuery→템플릿 실행의 방향이다. 영역은 서버가 소유한 절과 SQL 구조를 묶는다. 조건이 절의 존재를 선택할 수 있지만 값의 문자열을 SQL 구조에 연결하지 않는다. 두 배치의 분기·합류·영역이 같다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 고정 절과 이름 기반 값이 하나의 실행 가능한 쿼리로 합쳐진다 |
| 6 | 여섯 노드와 여섯 간선이다. 검증된 검색 조건은 필요한 고정 절을 선택하고 별도의 이름 기반 값 맵을 만든다. 절 선택→SQL 구조와 SQL 구조·값 맵→BuiltQuery→템플릿 실행의 방향이다. 영역은 서버가 소유한 절과 SQL 구조를 묶는다. 조건이 절의 존재를 선택할 수 있지만 값의 문자열을 SQL 구조에 연결하지 않는다. 두 배치의 분기·합류·영역이 같다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 검색 조건 분리 검증한 조건으로 고정 절 선택과 이름 기반 값 맵을 만듭니다. | 구조: 서버 고정 절 · 값: 별도 이름 맵 | 값 문자열을 SQL 구조에서 분리합니다. |
| BuiltQuery 합류 SQL 구조와 값 맵을 BuiltQuery로 합칩니다. | 산출물: BuiltQuery · 구조: 고정 절 조합 | 분리한 두 입력이 실행 계약으로 모입니다. |
| 템플릿 실행 템플릿에 쿼리와 바인딩 값을 넘깁니다. | 실행: template · 값: 파라미터 바인딩 | 사용자 값은 SQL 구조 대신 바인딩에 쓰입니다. |
고정 절과 이름 기반 값이 하나의 실행 가능한 쿼리로 합쳐진다
검색 조건이 고정 SQL 절과 값 맵으로 분리된 뒤 BuiltQuery에 합류해 실행되므로 구성 상태가 달라진다.
DYNAMIC SQL · STRUCTURE + BOUND VALUES
동적 SQL은 고정 절과 바인딩 값의 조합이다
서버가 고른 SQL 절과 그 값을 같은 분기에서 추가합니다. 검색어가 SQL 구조나 정렬 방식을 바꾸지 않습니다.
검색 · PostSearch → BuiltQuery → 실행
- 항상 적용할 절
member_id, 포함 날짜 범위, size + 1 제한을 추가합니다.
- 필요한 절과 값을 함께 추가
keyword는 리터럴 LIKE, minimumCharacters는 길이 조건, after는 게시일·ID 배타 커서 조건입니다.
- 고정 순서로 실행
BuiltQuery의 SQL과 이름 기반 값을 전달합니다. 정렬은 published_on DESC, id DESC로 고정합니다.
삽입 · 명령 → 일곱 쓰기 열 → ID
- 어댑터 내부 보조 도구
SimpleJdbcInsert는 열 이름 반복을 줄이는 예제입니다. 별도 애플리케이션 포트가 아닙니다.
- 쓰기 열을 명시
다섯 명령 필드와 생성 시각, 초기 버전만 매핑합니다. 생성 키 열은 id입니다.
- 양수 ID 하나만 허용
키 누락과 비양수 키는 실패입니다. 실제 리포지토리가 저장 뒤 스냅샷 확인까지 담당합니다.
검색어의 %, _, 역슬래시는 데이터로 이스케이프합니다. 절 선택과 값 바인딩은 다른 책임입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 여섯 노드와 여섯 간선이다. 검증된 검색 조건은 필요한 고정 절을 선택하고 별도의 이름 기반 값 맵을 만든다. 절 선택→SQL 구조와 SQL 구조·값 맵→BuiltQuery→템플릿 실행의 방향이다. 영역은 서버가 소유한 절과 SQL 구조를 묶는다. 조건이 절의 존재를 선택할 수 있지만 값의 문자열을 SQL 구조에 연결하지 않는다. 두 배치의 분기·합류·영역이 같다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 고정 절과 이름 기반 값이 하나의 실행 가능한 쿼리로 합쳐진다 |
| 6 | 여섯 노드와 여섯 간선이다. 검증된 검색 조건은 필요한 고정 절을 선택하고 별도의 이름 기반 값 맵을 만든다. 절 선택→SQL 구조와 SQL 구조·값 맵→BuiltQuery→템플릿 실행의 방향이다. 영역은 서버가 소유한 절과 SQL 구조를 묶는다. 조건이 절의 존재를 선택할 수 있지만 값의 문자열을 SQL 구조에 연결하지 않는다. 두 배치의 분기·합류·영역이 같다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 검색 조건 분리 검증한 조건으로 고정 절 선택과 이름 기반 값 맵을 만듭니다. | 구조: 서버 고정 절 · 값: 별도 이름 맵 | 값 문자열을 SQL 구조에서 분리합니다. |
| BuiltQuery 합류 SQL 구조와 값 맵을 BuiltQuery로 합칩니다. | 산출물: BuiltQuery · 구조: 고정 절 조합 | 분리한 두 입력이 실행 계약으로 모입니다. |
| 템플릿 실행 템플릿에 쿼리와 바인딩 값을 넘깁니다. | 실행: template · 값: 파라미터 바인딩 | 사용자 값은 SQL 구조 대신 바인딩에 쓰입니다. |
그림은 검증된 검색 조건이 서버가 가진 고정 절 가운데 필요한 것을 골라 SQL 구조를 만들고, 그와 따로 이름 기반 값의 맵을 만든 뒤, 둘을 BuiltQuery로 합쳐 템플릿에서 실행하는 흐름을 보여 줍니다. 조건은 어떤 절이 들어갈지만 정할 뿐, 값의 문자열이 SQL 구조에 들어가지 않습니다. 아래 칸들은 언제나 들어가는 절과 필요할 때만 더하는 절, 고정된 정렬, 그리고 SimpleJdbcInsert가 맡는 좁은 일을 나눠 설명합니다.
글자 그대로의 LIKE 패턴
포함 검색에서 %와 _는 호출한 쪽이 보낸 데이터입니다. LIKE 패턴에서 두 문자가 와일드카드로 쓰이는 규칙과 ESCAPE 절은 조건식과 필터링에서 다룹니다. 여기서는 먼저 역슬래시 자체를 이스케이프한 뒤 두 와일드카드를 이스케이프하고, 대소문자를 구분하지 않고 비교하려고 Locale.ROOT로 바꿉니다.
package board.jdbc;
import java.util.Locale;
import java.util.Objects;
final class LikePattern {
private LikePattern() {
}
static String containsLiteral(String input) {
Objects.requireNonNull(input, "input");
if (input.isBlank()) {
throw new IllegalArgumentException("search text is blank");
}
String escaped = input.toLowerCase(Locale.ROOT)
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_");
return "%" + escaped + "%";
}
}검색 값의 변환은 어댑터 안에서 할 일입니다. 원래의 PostSearch는 고치지 않으며, SQL에는 ESCAPE '\'를 명시해 데이터베이스가 같은 의미로 해석하게 합니다. 자바 문자열 리터럴에서는 역슬래시를 두 번 써서 escape '\\'로 적지만, 데이터베이스에 전달되는 SQL에는 역슬래시가 하나만 남습니다.
예를 들어 검색어 A_B%는 소문자로 바꾸면 a_b%, 글자 그대로 이스케이프하면 a\_b\%, 포함 검색을 위해 바깥에 %를 붙이면 %a\_b\%%가 됩니다. 처음과 마지막의 %는 포함 검색의 와일드카드이고, 역슬래시가 앞에 붙은 _와 %는 찾으려는 원래의 문자입니다. 이 결과도 SQL 문자열에 직접 붙이지 않고 :keyword의 값으로 바인딩합니다.
SQL 절과 파라미터를 함께 조립하기
소유자와 날짜의 필수 절은 언제나 들어갑니다. 검색어와 최소 본문 길이, 다음 커서는 각각 하나의 분기에서 SQL 절과 값을 동시에 더하며, 정렬은 호출한 쪽이 고를 수 없게 고정합니다.
package board.jdbc;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import board.application.PostSearch;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
public final class PostSearchSql {
public BuiltQuery build(PostSearch search) {
Objects.requireNonNull(search, "search");
var clauses = new ArrayList<String>();
var parameters = new MapSqlParameterSource()
.addValue("memberId", search.memberId())
.addValue("from", search.from())
.addValue("to", search.to())
.addValue("limit", search.size() + 1);
clauses.add("member_id = :memberId");
clauses.add("published_on between :from and :to");
if (search.keyword() != null) {
clauses.add("(lower(title) like :keyword escape '\\' "
+ "or lower(content) like :keyword escape '\\')");
parameters.addValue(
"keyword", LikePattern.containsLiteral(search.keyword()));
}
if (search.minimumCharacters() != null) {
clauses.add("char_length(content) >= :minimumCharacters");
parameters.addValue(
"minimumCharacters", search.minimumCharacters());
}
search.after().ifPresent(cursor -> {
clauses.add("(published_on < :cursorDate "
+ "or (published_on = :cursorDate and id < :cursorId))");
parameters
.addValue("cursorDate", cursor.publishedOn())
.addValue("cursorId", cursor.id());
});
String sql = """
select id, member_id, title, content, published_on,
client_request_id, created_at, version
from posts
where %s
order by published_on desc, id desc
limit :limit
""".formatted(String.join("\n and ", clauses));
return new BuiltQuery(sql, parameters);
}
public record BuiltQuery(
String sql,
MapSqlParameterSource parameters
) {
public BuiltQuery {
Objects.requireNonNull(sql, "sql");
Objects.requireNonNull(parameters, "parameters");
}
}
}limit에는 size + 1을 넣습니다. 요청한 수보다 한 행 더 읽었을 때만 다음 커서를 만들 수 있습니다. 조회 대상과 정렬 키가 페이지 사이에 바뀌지 않는다면, 정렬과 배타적 커서가 같은 (published_on, id) 튜플을 쓰므로 같은 날짜의 행도 빠뜨리지 않습니다. 다만 페이지 사이의 삽입과 삭제, 날짜 수정까지 하나의 과거 스냅샷으로 고정해 주지는 않습니다.
SimpleJdbcInsert가 맡는 좁은 일
간단한 삽입 도구는 열 이름의 반복을 줄여 주지만 또 하나의 저장소가 아닙니다. 다음 패키지 전용 보조 클래스는 생성 명령의 쓰기 열 일곱 개와 양수 키의 확인만 보여 줍니다.
package board.jdbc;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Objects;
import javax.sql.DataSource;
import board.application.DuplicatePostRequestException;
import board.application.PostPersistenceException;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
final class SimplePostInsert {
private final SimpleJdbcInsert insert;
SimplePostInsert(DataSource dataSource) {
this.insert = new SimpleJdbcInsert(
Objects.requireNonNull(dataSource, "dataSource"))
.withTableName("posts")
.usingColumns(
"member_id",
"title",
"content",
"published_on",
"client_request_id",
"created_at",
"version")
.usingGeneratedKeyColumns("id");
}
long insert(CreatePostCommand command, Instant createdAt) {
Objects.requireNonNull(command, "command");
Objects.requireNonNull(createdAt, "createdAt");
var parameters = new MapSqlParameterSource()
.addValue("member_id", command.memberId())
.addValue("title", command.title())
.addValue("content", command.content())
.addValue("published_on", command.publishedOn())
.addValue("client_request_id", command.clientRequestId())
.addValue("created_at", OffsetDateTime.ofInstant(
createdAt, ZoneOffset.UTC))
.addValue("version", 0L);
try {
Number key = insert.executeAndReturnKey(parameters);
if (key == null) {
throw failure("SimpleJdbcInsert did not return a key");
}
long id = key.longValue();
if (id <= 0) {
throw failure("SimpleJdbcInsert returned a non-positive key");
}
return id;
} catch (DuplicateKeyException exception) {
throw new DuplicatePostRequestException(
command.memberId(), command.clientRequestId(), exception);
} catch (PostPersistenceException exception) {
throw exception;
} catch (DataAccessException exception) {
throw new PostPersistenceException(
"SimpleJdbcInsert failed", exception);
}
}
private PostPersistenceException failure(String message) {
return new PostPersistenceException(
message, new IllegalStateException(message));
}
}사용 사례는 이 보조 클래스를 직접 보지 않습니다. 실제 명령 포트는 앞 글의 JdbcTemplatePostRepository 하나이며, 생성 뒤의 전체 스냅샷 확인과 수정·삭제의 규칙도 그 구현에 모입니다.
더 깊이: SimpleJdbcInsert는 언제 SQL을 만들까
SimpleJdbcInsert는 생성자에서 INSERT 문을 만들지 않습니다. Spring Framework 7.0.10의 AbstractJdbcInsert는 처음 실행될 때 compile()을 한 번 호출하고, 이때 DataSource에서 연결을 얻어 DatabaseMetaData를 읽습니다. 기본 설정에서는 제품 이름에 맞는 메타데이터 공급자를 고른 뒤 대상 테이블의 열 정보까지 조회하며, 이 조회를 건너뛰려면 withoutTableColumnMetaDataAccess()를 씁니다. 그래서 테이블 이름이 틀렸거나 메타데이터 권한이 없다는 문제는 객체를 만들 때가 아니라 첫 삽입에서 드러나고, 첫 호출만 연결을 한 번 더 쓰게 됩니다.
열 목록을 정하는 규칙도 이 단계에 있습니다. usingColumns로 열을 선언했다면 그 목록을 그대로 쓰고, 선언하지 않았다면 메타데이터에서 읽은 모든 열 가운데 생성 키 열만 뺍니다. 열을 선언하지 않은 삽입기는 테이블에 열이 새로 생기면, 애플리케이션이 다시 시작해 새 객체가 컴파일될 때부터 그 열도 INSERT에 넣습니다. 이 글처럼 쓰기 열 일곱 개를 명시하면 스키마가 바뀌어도 SQL의 모양이 저절로 바뀌지 않습니다. 또 한 번 컴파일된 뒤에는 withTableName이나 usingColumns 같은 설정을 바꾸려 하면 InvalidDataAccessApiUsageException이 납니다. API 문서는 이 클래스를 여러 스레드에서 다시 쓸 수 있는 객체로 설명하므로, 이 글처럼 생성자에서 설정을 끝낸 객체 하나를 필드로 두고 함께 쓰면 됩니다.
쿼리 모양의 단위 검증
빌더 테스트는 고른 절과 이름 기반 값이 함께 나타나는지 확인합니다. 실제 SQL의 실행과 글자 그대로의 매칭은 JDBC 통합 테스트와 세 어댑터의 공통 테스트가 맡습니다.
package board.jdbc;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.LocalDate;
import java.util.Optional;
import board.application.PostCursor;
import board.application.PostSearch;
import org.junit.jupiter.api.Test;
class PostSearchSqlTest {
private static final LocalDate FROM = LocalDate.parse("2026-08-01");
private static final LocalDate TO = LocalDate.parse("2026-08-31");
private final PostSearchSql builder = new PostSearchSql();
@Test
void 필수_소유자와_날짜_정렬_limit은_항상_고정된다() {
var query = builder.build(new PostSearch(
41L, null, FROM, TO, null, Optional.empty(), 20));
assertThat(query.sql())
.contains("member_id = :memberId")
.contains("published_on between :from and :to")
.contains("order by published_on desc, id desc")
.contains("limit :limit")
.doesNotContain(":keyword", ":minimumCharacters", ":cursorId");
assertThat(query.parameters().getValue("memberId")).isEqualTo(41L);
assertThat(query.parameters().getValue("limit")).isEqualTo(21);
}
@Test
void percent_underscore_역슬래시는_순서대로_이스케이프된다() {
var query = builder.build(new PostSearch(
41L,
"100%_\\path",
FROM,
TO,
12,
Optional.empty(),
30));
assertThat(query.sql())
.contains("lower(title) like :keyword escape '\\'")
.contains("lower(content) like :keyword escape '\\'")
.contains("char_length(content) >= :minimumCharacters");
assertThat(query.parameters().getValue("keyword"))
.isEqualTo("%100\\%\\_\\\\path%");
assertThat(query.parameters().getValue("minimumCharacters"))
.isEqualTo(12);
}
@Test
void 다음_page는_날짜와_ID의_배타_튜플을_함께_사용한다() {
var cursor = new PostCursor(LocalDate.parse("2026-08-20"), 77L);
var query = builder.build(new PostSearch(
41L, "Spring", FROM, TO,
null, Optional.of(cursor), 10));
assertThat(query.sql())
.contains("published_on < :cursorDate")
.contains("published_on = :cursorDate and id < :cursorId")
.contains("order by published_on desc, id desc");
assertThat(query.parameters().getValue("cursorDate"))
.isEqualTo(cursor.publishedOn());
assertThat(query.parameters().getValue("cursorId"))
.isEqualTo(cursor.id());
assertThat(query.parameters().getValue("limit")).isEqualTo(11);
}
}문자열 테스트만으로 DB 방언까지 확인했다고 말하지 않습니다. JdbcTemplateRepositoryTest는 공유 스키마에서 %가 글자 그대로 검색되는 실제 결과를 확인하고, 공통 테스트는 _와 역슬래시, 공격 형태의 문자열까지 각 어댑터에 되풀이합니다.
정리
- 동적 SQL은 서버가 가진 고정 절만 고르고, 사용자의 값은 모두 이름 기반 파라미터로 바인딩합니다.
- LIKE 검색은 역슬래시부터 이스케이프한 뒤
%와_를 이스케이프하고, SQL에ESCAPE를 명시합니다. - 조건마다 하나의 분기에서 절과 값을 함께 더하고, 정렬은 고정하며
limit에는size + 1을 넣습니다. - 키셋 커서는 정렬과 같은
(published_on, id)튜플을 쓰므로 같은 날짜의 행도 빠뜨리지 않습니다. SimpleJdbcInsert는 열 이름을 줄이는 보조 도구일 뿐, 스냅샷 확인과 수정·삭제 규칙은 실제 저장소가 맡습니다.
연습 문제
1. 배타적 커서와 한 행 더 읽기
고정된 대상 집합에서 마지막으로 돌려준 튜플이 (2026-08-20, 77)이고, 날짜와 ID의 내림차순이라고 합시다. (2026-08-20, 76), (2026-08-20, 78), (2026-08-19, 90), (2026-08-21, 1) 가운데 다음 페이지 조건을 만족하는 행을 먼저 고르세요. 이어서 페이지 크기 2에서 다음 행이 세 개 발견되었을 때, 돌려줄 목록과 다음 커서는 어느 행을 기준으로 만드는지 설명하세요.
해설 보기
같은 날짜에서는 ID가 77보다 작은 76만 통과합니다. 더 이른 8월 19일의 ID 90은 ID가 커도 날짜에서 먼저 조건을 만족합니다. 같은 날짜의 78과 더 늦은 8월 21일의 1은 통과하지 않습니다.
세 행 가운데 정해진 순서로 처음 두 행만 돌려주고, 세 번째 행은 다음 페이지가 있다는 확인에만 씁니다. 다음 커서는 마지막으로 돌려준 두 번째 행의 튜플입니다. 세 번째 행을 커서로 보내면, 다음 요청의 배타적 조건이 아직 돌려주지 않은 그 행을 건너뜁니다. 이 계산은 대상과 정렬 키가 바뀌지 않는다는 조건에서만 맞습니다.
2. 정렬 방향을 고르게 하고 싶을 때
화면에서 오래된 순으로도 볼 수 있게 해 달라는 요구가 생겼습니다. 요청 파라미터 sort=asc를 받아 ORDER BY published_on 뒤에 그대로 붙이면 어떤 문제가 생기고, 어떻게 바꿔야 하나요? 커서 조건은 어떻게 달라지나요?
해설 보기
요청 값을 SQL 구조에 그대로 붙이면 SQL 주입의 통로가 되고, 허용하지 않은 열이나 표현으로 정렬하게 될 수 있습니다. 대신 정렬을 NEWEST와 OLDEST 같은 열거형으로 받고, 각각에 대해 서버가 가진 완성된 ORDER BY 절과 커서 비교식을 짝지어 고릅니다.
내림차순에서는 커서보다 날짜가 작거나 날짜가 같고 ID가 작은 행이 다음이지만, 오름차순에서는 날짜가 크거나 날짜가 같고 ID가 큰 행이 다음입니다. 그래서 커서에도 어떤 정렬로 만든 것인지를 담아, 다른 정렬의 커서가 섞여 들어오면 거부합니다. 공통 테스트에는 두 정렬 각각의 순회 결과를 더해 모든 어댑터에서 확인합니다.
다음 글 데이터베이스 테스트에서는 내장 DB 테스트가 주는 빠른 피드백과 운영 데이터베이스와의 차이를 나눠 봅니다.