728x90
320x100
[구현하고자 하는 화면]
소스 이해를 위해 구현하고자 하는 화면을 먼저 띄워보자면
상단의 검색조건을 선택할 때마다 그 검색조건에 맞는 결과값들이 하단에 바로바로 조회되는 방식을 만들고 싶었다.
검색조건 값들도 하드코딩이 아니라 동적으로 불러오는 방식으로 하고 싶었음.
0. 기출문제 검색조건 조회
SearchCriteria.java
package knou.cbt.domain.exam;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
@Getter @Setter
@RequiredArgsConstructor
public class SearchCriteria {
private String departmentId;
private String subjectId;
private String year;
private String semester;
private String category;
public SearchCriteria(String departmentId, String subjectId, String year, String semester, String category) {
this.departmentId = departmentId;
this.subjectId = subjectId;
this.year = year;
this.semester = semester;
this.category = category;
}
}
- 검색조건을 담기 위한 객체 생성했다.
SearchResult.java
package knou.cbt.domain.exam;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
@Getter @Setter
@RequiredArgsConstructor
public class SearchResult {
private String searchKey;
private String searchValue;
}
- 조회된 검색조건을 key, value 형태로 조회하기 위해 객체를 하나 생성했다.
ExamSearchService.java
package knou.cbt.domain.exam;
import java.util.List;
public interface ExamSearchService {
List<SearchResult> searchDepartmentId();
List<SearchResult> searchSubjectId(String searchValue);
List<SearchResult> searchYear(String searchValue);
List<SearchResult> searchSemester(String searchValue);
List<SearchResult> searchCategory(String searchValue);
}
ExamSearchServiceImpl.java
package knou.cbt.domain.exam;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ExamSearchServiceImpl implements ExamSearchService {
private final ExamSearchMapper examSearchMapper;
@Override
public List<SearchResult> searchDepartmentId() {
return examSearchMapper.searchDepartmentId();
}
@Override
public List<SearchResult> searchSubjectId(String searchValue) {
return examSearchMapper.searchSubjectId(searchValue);
}
@Override
public List<SearchResult> searchYear(String searchValue) {
return examSearchMapper.searchYear(searchValue);
}
@Override
public List<SearchResult> searchSemester(String searchValue) {
return examSearchMapper.searchSemester(searchValue);
}
@Override
public List<SearchResult> searchCategory(String searchValue) {
return examSearchMapper.searchCategory(searchValue);
}
}
ExamSearchMapper.xml
package knou.cbt.domain.exam;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ExamSearchMapper {
List<SearchResult> searchDepartmentId();
List<SearchResult> searchSubjectId(@Param("searchValue")String searchValue);
List<SearchResult> searchYear(@Param("searchValue")String searchValue);
List<SearchResult> searchSemester(@Param("searchValue")String searchValue);
List<SearchResult> searchCategory(@Param("searchValue")String searchValue);
}
ExamSearchMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="knou.cbt.domain.exam.ExamSearchMapper">
<select id="searchDepartmentId" resultType="knou.cbt.domain.exam.SearchResult">
SELECT DISTINCT SUBSTR(EXAM_ID, 1, 1) AS SEARCH_KEY
, ( SELECT DISTINCT DEPARTMENT_NAME
FROM DEPARTMENT
WHERE USE_YN = 'Y'
AND DEPARTMENT_ID = SUBSTR(EXAM_ID, 1, 1) ) AS SEARCH_VALUE
FROM EXAM
WHERE USE_YN = 'Y'
</select>
<select id="searchSubjectId"
parameterType="java.lang.String"
resultType="knou.cbt.domain.exam.SearchResult">
SELECT SUBJECT_ID AS SEARCH_KEY
, ( SELECT SUBJECT_NAME
FROM SUBJECT S
WHERE USE_YN = 'Y'
AND SUBJECT_ID = SUBSTR(EXAM_ID, 1, 4)) AS SEARCH_VALUE
FROM EXAM
WHERE USE_YN = 'Y'
AND SUBSTR(EXAM_ID, 1, 1) = #{searchValue}
</select>
<select id="searchYear"
parameterType="java.lang.String"
resultType="knou.cbt.domain.exam.SearchResult">
SELECT DISTINCT EXAM_YEAR AS SEARCH_KEY
, EXAM_YEAR AS SEARCH_VALUE
FROM EXAM
WHERE USE_YN = 'Y'
AND SUBSTR(EXAM_ID, 1, 4) = #{searchValue}
</select>
<select id="searchSemester"
parameterType="java.lang.String"
resultType="knou.cbt.domain.exam.SearchResult">
SELECT DISTINCT SEMESTER AS SEARCH_KEY
, SEMESTER AS SEARCH_VALUE
FROM EXAM
WHERE USE_YN = 'Y'
AND SUBSTR(EXAM_ID, 1, 8) = #{searchValue}
</select>
<select id="searchCategory"
parameterType="java.lang.String"
resultType="knou.cbt.domain.exam.SearchResult">
SELECT DISTINCT EXAM_CATEGORY AS SEARCH_KEY
, CASE
WHEN EXAM_CATEGORY = 1 THEN '기말'
WHEN EXAM_CATEGORY = 2 THEN '동계계절'
ELSE '하계계절'
END AS SEARCH_VALUE
FROM EXAM
WHERE USE_YN = 'Y'
AND SUBSTR(EXAM_ID, 1, 9) = #{searchValue}
</select>
</mapper>
- 이거 쿼리 짜는 데 상당히 많은 시간을 소요했다. 내가......데이터베이스 구조를 잘못 짰나 싶을 정도였다. 아직도 잘못짠거 같은 느낌적인 느낌.
ExamSearchController.java
package knou.cbt.web.exam;
import knou.cbt.domain.exam.ExamSearchService;
import knou.cbt.domain.exam.SearchResult;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Controller
@RequiredArgsConstructor
@RequestMapping("/search")
public class ExamSearchController {
private final ExamSearchService examSearchService;
@GetMapping("/api")
public ResponseEntity<List<SearchResult>> searchApiList(
@RequestParam("searchKey") String searchKey
, @RequestParam(value = "searchValue", required = false) String searchValue) {
List<SearchResult> resultList = switch (searchKey) {
case "departmentId" -> examSearchService.searchDepartmentId();
case "subjectId" -> examSearchService.searchSubjectId(searchValue);
case "year" -> examSearchService.searchYear(searchValue);
case "semester" -> examSearchService.searchSemester(searchValue);
default -> examSearchService.searchCategory(searchValue);
};
return ResponseEntity.ok().body(resultList);
}
}
- 아예 컨트롤러도 따로 빼서 작업했다.
1. 기출문제 목록조회
ExamService.java
package knou.cbt.domain.exam;
import java.util.List;
public interface ExamService {
List<ExamInfo> allExamList(int page, int size, SearchCriteria searchCriteria);
int countExams(int page, int size, SearchCriteria searchCriteria);
}
ExamServiceImpl.java
package knou.cbt.domain.exam;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ExamServiceImpl implements ExamService{
private final ExamMapper examMapper;
@Override
public List<ExamInfo> allExamList(int page, int size, SearchCriteria searchCriteria) {
int offset = (page - 1) * size;
return examMapper.allExamList(offset, size, searchCriteria);
}
@Override
public int countExams(int page, int size, SearchCriteria searchCriteria) {
int offset = (page - 1) * size;
return examMapper.countExams(offset, size, searchCriteria);
}
}
ExamMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="knou.cbt.domain.exam.ExamMapper">
<select id="allExamList"
parameterType="knou.cbt.domain.exam.SearchCriteria"
resultType="knou.cbt.domain.exam.ExamInfo">
SELECT
EXAM_ID
, (SELECT DEPARTMENT_NAME
FROM DEPARTMENT
WHERE DEPARTMENT_ID = SUBSTR(EXAM_ID, 1, 1)) AS DEPARTMENT_NAME
, SUBSTR(EXAM_ID, 1, 1) AS DEPARTMENT_ID
, ( SELECT SUBJECT_NAME
FROM SUBJECT
WHERE SUBJECT_ID = E.SUBJECT_ID ) AS SUBJECT_NAME
, SUBJECT_ID
, EXAM_YEAR
, GRADE
, SEMESTER
, EXAM_CATEGORY
FROM EXAM E
WHERE USE_YN = 'Y'
<if test='searchCriteria.departmentId != null'>
AND SUBSTR(EXAM_ID, 1, 1) = #{searchCriteria.departmentId}
</if>
<if test='searchCriteria.subjectId != null'>
AND SUBJECT_ID = #{searchCriteria.subjectId}
</if>
<if test='searchCriteria.year != null'>
AND EXAM_YEAR = #{searchCriteria.year}
</if>
<if test='searchCriteria.semester != null'>
AND SEMESTER = #{searchCriteria.semester}
</if>
<if test='searchCriteria.category != null'>
AND EXAM_CATEGORY = #{searchCriteria.category}
</if>
ORDER BY EXAM_ID
LIMIT #{offset}, #{limit}
</select>
<select id="countExams" parameterType="knou.cbt.domain.exam.SearchCriteria" resultType="int">
SELECT COUNT(*)
FROM EXAM
WHERE USE_YN = 'Y'
<if test='searchCriteria.departmentId != null'>
AND SUBSTR(EXAM_ID, 1, 1) = #{searchCriteria.departmentId}
</if>
<if test='searchCriteria.subjectId != null'>
AND SUBJECT_ID = #{searchCriteria.subjectId}
</if>
<if test='searchCriteria.year != null'>
AND EXAM_YEAR = #{searchCriteria.year}
</if>
<if test='searchCriteria.semester != null'>
AND SEMESTER = #{searchCriteria.semester}
</if>
<if test='searchCriteria.category != null'>
AND EXAM_CATEGORY = #{searchCriteria.category}
</if>
ORDER BY EXAM_ID
LIMIT #{offset}, #{limit}
</select>
</mapper>
ExamConroller.java
@GetMapping
public String allExamList(Model model
, @RequestParam(defaultValue = "1") int page
, @RequestParam(defaultValue = "10") int size
, @RequestParam(required = false) String departmentId
, @RequestParam(required = false) String subjectId
, @RequestParam(required = false) String year
, @RequestParam(required = false) String semester
, @RequestParam(required = false) String category){
SearchCriteria searchCriteria = new SearchCriteria(departmentId, subjectId, year, semester, category);
List<ExamInfo> exams = examService.allExamList(page, size, searchCriteria);
int totalExams = examService.countExams(page, size, searchCriteria);
int totalPages = (int) Math.ceil((double) totalExams / size);
model.addAttribute("exams", exams);
model.addAttribute("currentPage", page);
model.addAttribute("totalPages", totalPages);
model.addAttribute("totalExams", totalExams);
return "exam/allExamList";
}
@GetMapping("/api")
@ResponseBody
public ResponseEntity<Map<String, Object>> allExamListForSearch(
@RequestParam(defaultValue = "1") int page
, @RequestParam(defaultValue = "10") int size
, @RequestParam(required = false) String departmentId
, @RequestParam(required = false) String subjectId
, @RequestParam(required = false) String year
, @RequestParam(required = false) String semester
, @RequestParam(required = false) String category) {
SearchCriteria searchCriteria = new SearchCriteria(departmentId, subjectId, year, semester, category);
List<ExamInfo> exams = examService.allExamList(page, size, searchCriteria);
int totalExams = examService.countExams(page, size, searchCriteria);
int totalPages = (int) Math.ceil((double) totalExams / size);
Map<String, Object> result = new HashMap<>();
result.put("exams", exams);
result.put("currentPage", page);
result.put("totalExams", totalExams);
result.put("totalPages", totalPages);
return ResponseEntity.ok(result);
}
- 조회를 위한 컨트롤러가 2개 인데 각자 용도가 다르다. 원래는 첫번째 컨트롤러로만 이용해 조회를 했는데, 내가 원하는 기능이 검색조건이 변경될 때마다 그에 따라 검색되어 조회 값이 변경되는 형태를 만들고 싶었는데 도저히 저 상태로는 불가능했다. 그래서 생각한게 첫 조회할 때는 첫번째 방법으로 불러오고 그 이후에 검색조건이 변경될 때마다 아래의 컨트롤러를 호출하는 방식으로 분리했다.
- 일단 이렇게 해서 해결은 했는데 중복되는 부분이 많아서.....좀더 호율적으로 변경할 수 있지 않을까 하는 나의 작은...고민?
allExamList.html
<!DOCTYPE html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta charset="UTF-8">
<link href="/css/bootstrap.min.css" rel="stylesheet">
<title>기출문제 목록</title>
<script th:src="@{/js/searchSelect2.js}"></script>
</head>
<style>
.table-group-divider th td {
text-align: center;
}
</style>
<body>
<div class="container" id="content">
<div class="py-5 text-center">
<h2>기출문제 목록</h2>
</div>
<div class="row">
<div class="col">
<select id="departmentId" class="form-select">
<option value="">==학과==</option>
</select>
</div>
<div class="col">
<select id="subjectId" class="form-select" disabled>
<option selected>== 과목 ==</option>
</select>
</div>
<div class="col">
<select id="year" class="form-select" disabled>
<option selected>== 년도 ==</option>
</select>
</div>
<div class="col">
<select id="semester" class="form-select" disabled>
<option selected>== 학기 ==</option>
</select>
</div>
<div class="col">
<select id="category" class="form-select" disabled>
<option selected>== 구분 ==</option>
</select>
</div>
</div>
<hr class="my-4">
<span id="count" style="font-weight: bold;" th:text="|총 ${totalExams} 건|"></span>
<div>
<table class="table">
<thead>
<tr>
<th scope="col">학과</th>
<th scope="col">과목</th>
<th scope="col">학년</th>
<th scope="col">시험년도</th>
<th scope="col">학기</th>
<th scope="col">구분</th>
<th scope="col"></th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr th:each="exam : ${exams}">
<td><a th:text="${exam.departmentName}">학과</a></td>
<td><a th:text="${exam.subjectName}">과목</a></td>
<td><a th:text="|${exam.grade} 학년|">학년</a></td>
<td><a th:text="|${exam.examYear} 년도|">시험년도</a></td>
<td><a th:text="|${exam.semester} 학기|">학기</a></td>
<td>
<a th:if="${exam.examCategory == 1}" th:text="'기말'">구분</a>
<a th:if="${exam.examCategory == 2}" th:text="'계절학기(동계)'">구분</a>
<a th:if="${exam.examCategory == 3}" th:text="'계절학기(하계)'">구분</a>
</td>
<td><button type="button" class="btn btn-primary">풀기</button></td>
</tr>
</tbody>
</table>
</div>
<hr class="my-4">
<nav aria-label="Page navigation example"
th:fragment="pagenation" id="pagination">
<ul class="pagination" style="display: flex; justify-content: center;">
<li class="page-item" th:classappend="${currentPage > 1} ? '' : 'disabled'">
<a class="page-link" th:href="@{/exam(page=1)}">First</a>
</li>
<li class="page-item" th:classappend="${currentPage > 1} ? '' : 'disabled'">
<a class="page-link" th:href="@{/exam(page=${currentPage - 1})}">Previous</a>
</li>
<li class="page-item" th:classappend="${page == currentPage} ? 'active' : ''"
th:each="page : ${#numbers.sequence(1, totalPages != null ? totalPages : 1)}">
<a class="page-link" th:href="@{/exam(page=${page})}" th:text="${page}"></a>
</li>
<li class="page-item" th:classappend="${currentPage < totalPages} ? '' : 'disabled'">
<a class="page-link" th:href="@{/exam(page=${currentPage + 1})}">Next</a>
</li>
<li class="page-item" th:classappend="${currentPage < totalPages} ? '' : 'disabled'">
<a class="page-link" th:href="@{/exam(page=${totalPages})}">Last</a>
</li>
</ul>
</nav>
</div> <!-- /container -->
</body>
searchSeelct.js
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('select').forEach(function(select) {
select.addEventListener('change', onChange);
});
searchSelectChange("departmentId", null);
});
function onChange() {
const selectId = this.id;
const selects = {
departmentId: document.getElementById("departmentId").value,
subjectId: document.getElementById("subjectId").value,
year: document.getElementById("year").value,
semester: document.getElementById("semester").value,
category: document.getElementById("category").value
};
const resetSelect = (id, defaultText) => {
const select = document.getElementById(id);
select.innerHTML = `<option value="">== ${defaultText} ==</option>`;
select.disabled = true;
};
const updateSelectOptions = (searchKey, searchValue, defaultText) => {
searchSelectChange(searchKey, searchValue);
document.getElementById(searchKey).disabled = false;
};
const handleFetch = (url) => {
fetch(url, { method: "GET" })
.then(response => {
if (!response.ok) throw new Error("HTTP request failed");
return response.json();
})
.then(data => {
updateTable(data.exams, data.exams.length);
createPagination(data.totalPages, data.currentPage);
})
.catch(error => console.error("Error: ", error));
};
switch (selectId) {
case 'departmentId':
resetSelect('subjectId', '과목');
resetSelect('year', '년도');
resetSelect('semester', '학기');
resetSelect('category', '구분');
if (this.value) {
updateSelectOptions("subjectId", this.value, "과목");
document.getElementById("subjectId").disabled = false;
handleFetch(`/exam/api?departmentId=${this.value || ''}`);
} else {
handleFetch(`/exam/api`)
}
break;
case 'subjectId':
resetSelect('year', '년도');
resetSelect('semester', '학기');
resetSelect('category', '구분');
if (this.value) {
updateSelectOptions("year", this.value, "년도");
handleFetch(`/exam/api?departmentId=${selects.departmentId}&subjectId=${this.value || ''}`);
} else {
handleFetch(`/exam/api?departmentId=${selects.departmentId}`)
}
break;
case 'year':
resetSelect('semester', '학기');
resetSelect('category', '구분');
if (this.value) {
updateSelectOptions("semester", selects.subjectId + this.value, "학기");
handleFetch(`/exam?departmentId=${selects.departmentId}&subjectId=${selects.subjectId}&year=${this.value || ''}`);
} else {
handleFetch(`/exam?departmentId=${selects.departmentId}&subjectId=${selects.subjectId}`)
}
break;
case 'semester':
resetSelect('category', '구분');
if (this.value) {
updateSelectOptions("category", selects.subjectId + selects.year + this.value, "구분");
handleFetch(`/exam?departmentId=${selects.departmentId}&subjectId=${selects.subjectId}&year=${selects.year}&semester=${this.value || ''}`);
} else {
handleFetch(`/exam?departmentId=${selects.departmentId}&subjectId=${selects.subjectId}&year=${selects.year}`)
}
break;
case 'category':
if (this.value) {
handleFetch(`/exam?departmentId=${selects.departmentId}&subjectId=${selects.subjectId}&year=${selects.year}&semester=${selects.semester}&category=${this.value || ''}`);
} else {
handleFetch(`/exam?departmentId=${selects.departmentId}&subjectId=${selects.subjectId}&year=${selects.year}&semester=${selects.semester}`);
}
break;
}
}
function searchSelectChange(searchKey, searchValue) {
fetch(`/search/api?searchKey=${searchKey}&searchValue=${searchValue || ''}`, { method: "GET" })
.then(response => {
if (!response.ok) throw new Error("HTTP request failed");
return response.json();
})
.then(datas => {
const select = document.getElementById(searchKey);
select.innerHTML = `<option value="">== ${getDefaultText(searchKey)} ==</option>`;
datas.forEach(data => {
const opt = document.createElement("option");
opt.value = data.searchKey;
opt.text = data.searchValue;
select.appendChild(opt);
});
})
.catch(error => console.error("Error: ", error));
}
function getDefaultText(key) {
const texts = {
departmentId: "학과",
subjectId: "과목",
year: "년도",
semester: "학기",
category: "구분"
};
return texts[key] || '';
}
function updateTable(exams, length) {
const tbody = document.querySelector(".table-group-divider");
tbody.innerHTML = exams.map(exam => `
<tr>
<td>${exam.departmentName}</td>
<td>${exam.subjectName}</td>
<td>${exam.grade} 학년</td>
<td>${exam.examYear} 년도</td>
<td>${exam.semester} 학기</td>
<td>${getCategoryText(exam.examCategory)}</td>
<td><button class="btn btn-primary">풀기</button></td>
</tr>`).join('');
document.getElementById("count").textContent = `총 ${length} 건`;
}
function getCategoryText(category) {
const categories = {
1: "기말",
2: "계절학기(동계)",
3: "계절학기(하계)"
};
return categories[category] || '';
}
function createPagination(totalPages, currentPage) {
const pagination = document.getElementById("pagination");
const paramArr = Array.from(document.querySelectorAll('select')).reduce((params, select) => {
if (select.value) params[select.id] = select.value;
return params;
}, {});
const paramString = Object.entries(paramArr).map(([key, value]) => `&${key}=${value}`).join('');
const createPageItem = (text, page, isDisabled) => `
<li class="page-item ${isDisabled ? 'disabled' : ''}">
<a class="page-link" href="#" data-page="${page}">${text}</a>
</li>`;
const pageItems = [
createPageItem("First", 1, currentPage === 1),
createPageItem("Previous", currentPage - 1, currentPage === 1),
...Array.from({ length: totalPages }, (_, i) => createPageItem(i + 1, i + 1, false)).join(''),
createPageItem("Next", currentPage + 1, currentPage === totalPages),
createPageItem("Last", totalPages, currentPage === totalPages)
].join('');
pagination.innerHTML = `<ul class="pagination" style="display: flex; justify-content: center;">${pageItems}</ul>`;
pagination.querySelectorAll('a[data-page]').forEach(link => {
link.addEventListener('click', function (event) {
event.preventDefault();
const page = this.getAttribute('data-page');
fetch(`/exam?page=${page}${paramString}`)
.then
(response => response.json())
.then(data => {
updateTable(data.exams, data.exams.length);
createPagination(data.totalPages, data.currentPage);
})
.catch(error => console.error("Error: ", error));
});
});
}
- 가장 많은 시간과 많은 기능이 들어간 곳........
- 검색조건을 동적으로 가져오면서 검색조건을 선택할 때마다 바로 하위의 검색조건이 열리고 나머지는 닫혀야 함.
- 검색조건에 따른 조회결과가 하단에 바뀌어야 하면서 그 동시에 페이지네이션도 착착 되어야 함.
- 이걸 다 하려니까......시간 제일 오래걸림. 그리고 하고나서 너무 중복 많아서 지피티의 힘을 빌려 한번 리팩토링 함....
[구현화면]
사실 맨 위의 화면과 같은 화면이지만;
암튼 구현 완.....후우....너무 힘들었다...조회 화면에 기능이 추가되지 않는 이상 더이상 손 안될 정도로 했다.....
728x90
320x100
'💻 뚝딱뚝딱 > 방통대CBT' 카테고리의 다른 글
[개발일지#005] 시험풀기 화면 구현 (레이아웃, 안푼문제, 소요시간 등) (0) | 2024.06.20 |
---|---|
[개발일지#003] 기본 부트스트랩 적용 / 기출문제 전체조회 구현 (0) | 2024.06.07 |
[개발일지#002] 스프링 프로젝트 생성 및 Mybatis 연결 (0) | 2024.06.07 |
[개발일지#001] 데이터베이스 설계 및 생성 (0) | 2024.06.06 |
[개발일지#000] 방통대CBT 제작계기 & 사용기술스택 & 요구사항 (0) | 2024.06.05 |