스프링 - 검색 처리 구현, 동적 쿼리에 관하여..
스프링 - 검색 처리 구현, 동적 쿼리에 관하여..
검색 기능은 검색 조건과 키워드로 나눠 생각해볼 수 있다.
검색 조건은 일반적으로 select 태그나 checkbox를 이용한다. 최근에는 select를 일반 사용자들의 경우에, 관리자용이나 검색 기능이 강한 경우 checkbox를 이용하는 경우가 대부분이다.
1-1. 검색 기능과 SQL
제목 / 내용 / 작성자 : 단일 항목 검색
제목 or 내용 , 제목 or 작성자, 내용 or 작성자, 제목 or 내용 or 작성자 : 다중 항목
단일 항목은 인라인뷰 안쪽에 필요한 데이터를 가져올 때 검색 조건이 적용되어야 하기 때문에 where 문 뒤에 검색 조건이 추가되고 ROWNUM 조건이 뒤따르게 하면 문제없다.
1-1-1. MyBatis의 동적 SQL..
검색 조건이 변하면 SQL문 역시 변하므로 XML이나 어노테이션과 같이 고정된 문자열을 작성하는 방식으론 제대로 처리할 수 없다.
다행히 MyBatis는 동적 태그 기능을 이용해 SQL을 파라미터의 조건에 맞게 조정할 수 있는 기능을 제공한다.
1-1-2. MyBatis의 동적 태그들..
- if
- choose(when, otherwise)
- trim(where, set)
- foreach
- 검색 조건이 'T'면 : 제목이 키워드인 항목을 검색
- 검색 조건이 'C'면 : 내용이 키워드인 항목을 검색
- 검색 조건이 'W'면 : 작성자가 키워드인 항목을 검색
| (파이프 연산자)
: DBMS에서는 컨케이트네이션(연결) 이라는 뜻이다.
<if test="type=='T'.toString()">
(title like '%' || #{keyword} || '%')
</if>
<if test="type=='C'.toString()">
(content like '%' || #{keyword} || '%')
</if>
<if test="type=='W'.toString()">
(writer like '%' || #{keyword} || '%')
</if>
if 안에 들어가는 표현식은 OGNL 표현식이다.
<choose>
<when test="type=='T'.toString()">
(title like '%' || #{keyword} || '%')
</when>
<when test="type=='T'.toString()">
(title like '%' || #{keyword} || '%')
</when>
<when test="type=='T'.toString()">
(title like '%' || #{keyword} || '%')
</when>
<otherwise>
(title like '%' || #{keyword} || '%' OR content like '%' || #{keyword} || '%')
</otherwise>
</choose>
태그 안쪽에서 SQL이 생성되면 WHERE 구문이 붙고, 그렇지 않은 경우 생성되지 않는다.
SELECT * FROM TBL_BOARD
<where>
<if test="bno != null">
bno = #{bno}
</if>
</where>
은 태그의 내용을 앞의 내용과 관련되어 원하는 접두/접미를 처리할 수 있다.
SELECT * FROM TBL_BOARD
<where>
<if test="bno != null">
bno = #{bno}
</if>
<trim prefixOverrides = "and">
rownum = 1
</trim>
</where>
- bno가 null 일 때
- SELECT * FROM TBL_BOARD WHERE ROWNUM = 1
- bno가 null이 아닐 때
- SELECT * FROM TBL_BOARD WHERE BNO = #{bno} AND ROWNUM = 1
trim은 prefix, suffix, prefixOverrides, suffixOverrides 속성 지정 가능.
Overrides 는 '기각하다.' 의미를 가지고 있다.
prefixOverrides 는 앞에 조건식이 있는지, 없는지에 따라 판단한다. 조건식이 if문에 들어가있으면 조건식이 있을때 추가하고 없을 때 생략하고 한다. 하지만, 만약에 무조건 앞에 조건식이 있으면 prefixOverrides가 추가된다.
List, 배열, 맵 등을 이용해 루프 처리 가능. 주로 IN 조건에서 많이 사용한다.
예를 들어 제목은 'PS5'로, 내용을 '판매' 값으로 이용한다면
Map<String, String> map = new HashMap<>();
map.put("T", "PS5");
map.put("C", "판매");
작성된 Map을 파라미터로 전달하고 foreach를 이용한다.
SELECT * FROM TBL_BOARD
<trim prefix="where (" suffix=")" prefixOverrides="OR">
<!-- 이 전달받은 MAP(맵)의 index="key"(인덱스)는 어떤 값이며, 이 값을 어떤 변수(item)에 담을 것인가. -->
<foreach item="val" index="key" collection="map">
<trim prefix="OR">
<if test="key == 'C'.toString()">
title = #{val}
</if>
<if test="key == 'T'.toString()">
content = #{val}
</if>
<if test = "key == 'W'.toString()">
writer = #{val}
</if>
</trim>
</foreach>
</trim>
foreach를 배열이나 List를 이용해야 하는 경우 item 속성만을 이용하면 되고 MAP의 형태로 key와 value를 이용해야 하면 index와 item 속성을 둘 다 이용한다.
prefixOverrides 속성은 만약, 맨 앞에 있는 조건식에 OR이 있다면 제거하는 속성이다.
결과 *
SELECT * FROM TBL_TABLE WHERE(TITLE = '건담' OR CONTENT = '판매')
1-2. 검색 조건 처리를 위한 Criteria의 변화
검색 조건을 처리하기 위해 검색 조건과 검색에 사용하는 키워드가 필요하므로 기존의 Criteria를 확장할 필요가 있다.
package com.koreait.domain;
import lombok.AllArgsConstructor;
import lombok.Data;
//Criteria : 검색의 기준
@Data
@AllArgsConstructor
public class Criteria {
private int pageNum;
private int amount;
//[1]
//외부에서 사용자가 선택한 카테고리를 대표하는 문자열을 type으로 전달받는다(페이지에서 제목 또는 내용 선택 시 "TC" 전달됨)
//사용자가 검색하고 싶은 키워드를 keyword로 전달받는다.
private String type;
private String keyword;
public Criteria() {
this(1, 10);
}
public Criteria(int pageNum, int amount) {
this.pageNum = pageNum;
this.amount = amount;
}
//[2]
//사용자가 다중 항목을 선택했을 경우 각 항목을 분리해야 하기 때문에,
//단일 항목들을 문자열 타입으로 리턴해준다.
//MyBatis에서는 getter를 찾아서 실행하므로, typeArr변수 선언 없이 getter만 선언한다.
public String[] getTypeArr() {
return type == null ? new String[] {} : type.split("");
}
}
type과 keyword 변수 추가. getTypeArr은 검색 조건이 T, W, C로 구성되어 있을 때 검색 조건을 배열로 만들어 한 번에 처리하기 위함이다.
1-2-1. BoardMapper.xml에서 Criteria 처리
getListWithPaging()
<select id="getListWithPaging" resultType="com.koreait.domain.BoardVO">
SELECT BNO, TITLE, CONTENT, WRITER, REGDATE, UPDATEDATE
FROM
(SELECT /*+ INDEX_DESC(TBL_BOARD PK_BOARD) */ ROWNUM RN, BNO, TITLE, CONTENT, WRITER, REGDATE, UPDATEDATE
FROM TBL_BOARD
WHERE
<if test="type != null and keyword != null">
<trim prefix="(" suffix=") AND" prefixOverrides="OR">
<foreach item="type" collection="typeArr">
<trim prefix="OR">
<choose>
<when test="type=='T'.toString()">
(TITLE LIKE '%'||#{keyword}||'%')
</when>
<when test="type=='C'.toString()">
(CONTENT LIKE '%'||#{keyword}||'%')
</when>
<when test="type=='W'.toString()">
(WRITER LIKE '%'||#{keyword}||'%')
</when>
</choose>
</trim>
</foreach>
</trim>
</if>
<![CDATA[
ROWNUM <= #{pageNum} * #{amount})
WHERE RN > (#{pageNum} - 1) * #{amount}
]]>
</select>
<select id="getTotalCount" resultType="int">
SELECT COUNT(*) FROM TBL_BOARD WHERE bno > 0
</select>
검색 조건이 총 3가지이므로 6가지 조합이 가능하지만 각 문자열을 이용해 검색 조건을 결합하는 형태로 하면 3개의 동적 SQL 구문만으로 처리할 수 있다.
foreach를 이용해 검색 조건을 처리하는데 typeArr 속성을 이용한다.
MyBatis는 원하는 속성을 찾을 때 getTypeArr와 같이 이름에 기반을 두어 검색하므로 Criteria에서 만들어 둔 getTypeArr 결과인 문자열 배열 foreach 대상이 된다.
<choose>
안쪽의 동적 SQL은 OR title .... OR content ... OR writer 와 같은 구문을 만들어 내게 된다. 따라서 바깥족에서는 <trim>
을 이용해 맨 앞에 생성되는 OR를 없애준다.
BoardMapperTests
수정
@Test
public void testSearch() {
Criteria cri = new Criteria();
cri.setKeyword("제목");
cri.setType("TCW");
List<BoardVO> list = mapper.getListWithPaging(cri);
list.forEach(board -> log.info(board));
}
<sql><include>
와 검색 데이터 개수 처리
동적 SQL을 이용해 검색 조건을 처리하는 부분은 해당 데이터의 개수를 처리하는 부분에서도 동일하게 적용되어야 한다.
MyBatis는 <sql>
태그로 SQL 일부를 별도 보관하고 필요한 경우 include 시키는 형태로 사용할 수 있다.
<sql id="criteria">
<!-- [3] -->
<!-- Mapper 인터페이스로부터 전달받은 Criteria객체 안의 type과 keyword 둘 다 null이 아니라면 -->
<if test="type != null and keyword != null">
<!-- [7] -->
<!-- trim 태그 안에 있는 쿼리문을 기준으로 가장 앞에 있는 OR를 없애준다. -->
<!-- trim 태그 안에 있는 쿼리문 뒤에는 페이징 처리 조건식이 한 개 더 있기 때문에 마지막에(suffix) AND를 붙여준다. -->
<trim prefix="(" suffix=") AND" prefixOverrides="OR">
<!-- [4] -->
<!-- Criteria에 선언된 getTypeArr()메소드를 호출하여 단일항목이 들어 있는 배열의 길이만큼 반복해준다. -->
<!-- 각각의 단일 항목들이 item속성에 있는 type변수에 들어가게 된다. -->
<!-- 예 : "TW"는 2칸 문자열 배열이며, 2번 반복된다. [2]참고 -->
<foreach item="type" collection="typeArr">
<!-- [6] -->
<!-- 작성된 쿼리문마다 맨 앞에(prefix) OR를 붙여준다. -->
<trim prefix="OR">
<!-- [5] -->
<!-- 단일 항목 중 조건식이 참이라면 알맞는 쿼리문이 작성된다. -->
<choose>
<when test="type=='T'.toString()">
(TITLE LIKE '%'||#{keyword}||'%')
</when>
<when test="type=='C'.toString()">
(CONTENT LIKE '%'||#{keyword}||'%')
</when>
<when test="type=='W'.toString()">
(WRITER LIKE '%'||#{keyword}||'%')
</when>
</choose>
</trim>
</foreach>
</trim>
</if>
</sql>
<select id="getListWithPaging" resultType="com.koreait.domain.BoardVO">
SELECT BNO, TITLE, CONTENT, WRITER, REGDATE, UPDATEDATE
FROM
(SELECT /*+ INDEX_DESC(TBL_BOARD PK_BOARD) */ ROWNUM RN, BNO, TITLE, CONTENT, WRITER, REGDATE, UPDATEDATE
FROM TBL_BOARD
WHERE
<include refid="criteria"/>
<![CDATA[
ROWNUM <= #{pageNum} * #{amount})
WHERE RN > (#{pageNum} - 1) * #{amount}
]]>
</select>
<select id="getTotalCount" resultType="int">
SELECT COUNT(*) FROM TBL_BOARD WHERE
<include refid="criteria"></include>
<include refid="criteria"></include> bno > 0
</select>
1-3. 화면에서 검색 조건 처리
- 페이지 번호가 파라미터로 유지되었던 것처럼 검색 조건과 키워드 역시 화면 이동 시 같이 전송되어야 한다.
- 화면에서 검색 버튼을 클릭하면 새로 검색을 한다는 의미이므로 1페이지로 이동
- 한글의 경우 GET 방식을 쓰면 문제가 생길 수 있다.
1-3-1. 목록 화면에서 검색 처리
list.jsp
수정
<form action="/board/list" id="searchForm">
<div class="fields">
<div class="field">
<div style="text-align:center">
<select name="type">
<option value="">검색 기준</option>
<option value="T">제목</option>
<option value="C">내용</option>
<option value="W">작성자</option>
<option value="TC">제목 또는 내용</option>
<option value="TW">제목 또는 작성자</option>
<option value="TCW">전체</option>
</select>
<input id="keyword" type="text" name="keyword">
<a href="javascript:void(0)" class="search button primary icon solid fa-search">검색</a>
</div>
</div>
</div>
</form>
HTML을 보면 페이징 처리를 위해 만들어둔 form 태그에 select와 input 태그가 추가 된 것을 볼 수 있다.
form 내 button 기본 동작은 submit 이므로 별도의 처리 없이 검색이 되는지 확인하다.
검색 바튼의 이벤트 처리
list.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<!DOCTYPE html>
<html>
<head>
<title>Board</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<meta name="description" content="" />
<meta name="keywords" content="" />
<link rel="stylesheet" href="/resources/assets/css/main.css" />
<style>
.big-width{display:block;}
.small-width{display:none;}
.table-wrapper {overflow-x:hidden !important;}
select{width: 25%;display: inline;}
input[name='keyword']{width: 54%; display: inline;}
.search{width: 20%;}
@media (max-width: 918px){
.writer {display:none;}
.regDate {display:none;}
.updateDate {display:none;}
.big-width{display:none;}
.small-width{display:block;}
}
</style>
</head>
<body class="is-preload">
<!-- Main -->
<div id="main">
<div class="wrapper">
<div class="inner">
<!-- Elements -->
<header class="major">
<h1>Board</h1>
<p>게시판 목록</p>
</header>
<!-- Table -->
<h3><a href="/board/register" class="button small">글 등록</a></h3>
<div class="table-wrapper">
<table>
<thead>
<tr class="tHead">
<th class="bno">번호</th>
<th class="title">제목</th>
<th class="writer">작성자</th>
<th class="regDate">작성일</th>
<th class="updateDate">수정일</th>
</tr>
</thead>
<tbody>
<c:forEach var="board" items="${list}">
<tr class="tBody">
<td class="bno">${board.bno}</td>
<td class="title"><a href="/board/get?bno=${board.bno}&pageNum=${pageMaker.cri.pageNum}&amount=${pageMaker.cri.amount}">${board.title}</a></td>
<td class="writer">${board.writer}</td>
<td class="regDate">${board.regDate}</td>
<td class="updateDate">${board.updateDate}</td>
</tr>
</c:forEach>
</tbody>
<tfoot>
</tfoot>
</table>
<!-- A -->
<form action="/board/list" id="searchForm">
<div class="fields">
<div class="field">
<div style="text-align:center">
<select name="type">
<option value="">검색 기준</option>
<option value="T">제목</option>
<option value="C">내용</option>
<option value="W">작성자</option>
<option value="TC">제목 또는 내용</option>
<option value="TW">제목 또는 작성자</option>
<option value="TCW">전체</option>
</select>
<input id="keyword" type="text" name="keyword">
<a href="javascript:void(0)" class="search button primary icon solid fa-search">검색</a>
</div>
</div>
</div>
</form>
<div class="big-width" style="text-align:center;">
<c:if test="${pageMaker.prev}">
<a class="changePage" href="${1}"><code><<</code></a>
<a class="changePage" href="${pageMaker.startPage - 1}"><code><</code></a>
</c:if>
<c:forEach var="num" begin="${pageMaker.startPage}" end="${pageMaker.endPage}">
<c:choose>
<c:when test="${num eq pageMaker.cri.pageNum}">
<code>${num}</code>
</c:when>
<c:otherwise>
<a class="changePage" href="${num}"><code>${num}</code></a>
</c:otherwise>
</c:choose>
</c:forEach>
<c:if test="${pageMaker.next}">
<a class="changePage" href="${pageMaker.endPage + 1}"><code>></code></a>
<a class="changePage" href="${pageMaker.realEnd}"><code>>></code></a>
</c:if>
</div>
<div class="small-width" style="text-align:center;">
<c:if test="${pageMaker.cri.pageNum > 1}">
<a class="changePage" href="${1}"><code><<</code></a>
<a class="changePage" href="${pageMaker.cri.pageNum - 1}"><code><</code></a>
</c:if>
<code>${pageMaker.cri.pageNum}</code>
<c:if test="${pageMaker.cri.pageNum < pageMaker.realEnd}">
<a class="changePage" href="${pageMaker.cri.pageNum + 1}"><code>></code></a>
<a class="changePage" href="${pageMaker.realEnd}"><code>>></code></a>
</c:if>
</div>
<form id="actionForm" action="/board/list">
<input type="hidden" name="pageNum" value="${pageMaker.cri.pageNum}">
<input type="hidden" name="amount" value="${pageMaker.cri.amount}">
</form>
<!-- B -->
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script src="/resources/assets/js/jquery.min.js"></script>
<script src="/resources/assets/js/jquery.dropotron.min.js"></script>
<script src="/resources/assets/js/browser.min.js"></script>
<script src="/resources/assets/js/breakpoints.min.js"></script>
<script src="/resources/assets/js/util.js"></script>
<script src="/resources/assets/js/main.js"></script>
</body>
<script>
$("a.search").on("click", function(e){
e.preventDefault();
var searchForm = $("#searchForm");
if(!searchForm.find("option:selected").val()){
alert("검색 종류를 선택하세요.");
return false;
}
if(!searchForm.find("input[name='keyword']").val()){
alert("키워드를 입력하세요.");
return false;
}
searchForm.submit();
})
$(".changePage").on("click", function(e){
e.preventDefault();
var actionForm = $("#actionForm");
var pageNum = $(this).attr("href");
actionForm.find("input[name='pageNum']").val(pageNum);
actionForm.submit();
})
//alert("${result}");
var result = "${result}";
$(document).ready(function(){
if(result == '' || isNaN(result)){
return;
}
alert("게시글 " + result + "번이 등록되었습니다.")
})
</script>
</html>
브라우저에서 검색 버튼을 클릭하면 form 태그 전송을 막고 페이지 번호가 1이 되도록 처리.
검색 부분과 form 부분 두 개, javascript 부분 수정.
1-3-2. 조회 페이지에서 검색 처리
조회 페이지로의 이동은 이미 form 태그로 처리했으므로 별도의 처리가 필요친 않는다.
다만 Cirteria의 type과 keyword를 처리해야 함.
get.jsp
<li>
<input type="button" class="button" value="수정" onclick="location.href='/board/modify?bno=${board.bno}&pageNum=${cri.pageNum}&amount=${cri.amount}'"/>
<input type="submit" class="button" value="삭제"/>
<input type="hidden" name="pageNum" value="${cri.pageNum}">
<input type="hidden" name="amount" value="${cri.amount}">
</li>
1-3-3. 수정 / 삭제 페이지에서 검색 처리
조회 페이지에서 수정 / 삭제 페이지로 이동은 GET 방식으로 이동하고, 이동 방식 역시 form 태그를 이용하는 방식이므로 기존 form 태그에 추가적인 조건만 추가.
modify.jsp
<input type="hidden" name="pageNum" value="${cri.pageNum}">
<input type="hidden" name="amount" value="${cri.amount}">
수정 / 삭제 처리는 BoardController에서 Redirect 방식이므로 type과 keyword 조건도 리다이렉트에 포함해야 한다.
BoardController
// 수정과 삭제는 성공 시 result에 success를 담아서 view에 전달하기.
// 수정 처리와 테스트 구현
@PostMapping("/modify")
public String modify(BoardVO board, Criteria cri, RedirectAttributes rttr) {
log.info("modify : " + board);
if(service.modify(board)) {
rttr.addFlashAttribute("result", "success");
}
/*
* 항상 컨트롤러에 있는 클래스 타입의 매개변수는 생성자를 통해서 파라미터 값으로 초기화 한다.
* 만약 전달받은 파라미터 값에 매핑되는 생성자가 없다면 값을 전달받을 수 없다.
rttr.addAttribute("cri", cri); 이렇게 쓰면 안된다.
*/
// POST 방식으로 진행되는 수정과 삭제 처리는 보드컨트롤러에서 각각의 메서드 형태로 구현되었으므로
// 페이지 관련 파라미터 처리를 위해 변형해야 한다.
/*
* Flash는 세션의 남용을 방지하고자 1개의 파라미터만 전달할 수 있다.
* 따라서 여러 개를 전달할 수 있는 컬렉션에 담아서 넘기거나
* URL에 붙여서 전달하는 addAttribute() 방식을 사용해야 한다.
*/
// 따라서 반드시 해당 객체의 생성자에 전달할 필드명과 일치하도록 설정해주어야 한다.
rttr.addAttribute("pageNum", cri.getPageNum());
rttr.addAttribute("amount", cri.getAmount());
rttr.addAttribute("type", cri.getType());
rttr.addAttribute("keyword", cri.getKeyword());
return "redirect:/board/list";
}
// 삭제 처리와 테스트 구현
@GetMapping("/remove")
public String remove(@RequestParam("bno") Long bno, Criteria cri, RedirectAttributes rttr) {
log.info("remove : " + bno);
if(service.remove(bno)) {
rttr.addFlashAttribute("result", "success");
}
rttr.addAttribute("pageNum", cri.getPageNum());
rttr.addAttribute("amount", cri.getAmount());
rttr.addAttribute("type", cri.getType());
rttr.addAttribute("keyword", cri.getKeyword());
return "redirect:/board/list";
}
리다이렉트 GET 방식으로 이루어지므로 추가 파라미터를 처리해야 한다.
<script type="text/javascript">
$(document).ready(function() {
var formObj = $("form");
$('button').on("click", function(e){
e.preventDefault();
var operation = $(this).data("oper");
console.log(operation);
if(operation === 'remove') {
formObj.attr("action", "/board/remove");
} else if (operation === 'list'){
//move to list
formObj.attr("action", "/board/list").attr("method","get");
var pageNumTag = $("input[name='pageNum']").clone();
var amountTag = $("input[name='amount']").clone();
var keywordTag = $("input[name='keyword']").clone();
var typeTag = $("input[name='type']").clone();
formObj.empty();
formObj.append(pageNumTag);
formObj.append(amountTag);
formObj.append(keywordTag);
formObj.append(typeTag);
}
formObj.submit();
});
});
</script>
<%@include file="../includes/footer.jsp"%>
UriComponentBuilder를 이용하는 링크 생성
웹 페이지에서 매번 파라미터를 유지하는 일이 번거롭고 힘들 때 유용하다.
org.springframework.web.util.UriComponentsBuilders
는 여러 개의 파라미터들을 연결해서 URL의 형태로 만들어주는 기능을 한다.
Criteria
클래스 추가
public String getListLink() {
UriComponentsBuilder builder = UriComponentsBuilder.fromPath("")
.queryParam("pageNum", this.pageNum)
.queryParam("amount", this.getAmount())
.queryParam("type", this.getType())
.queryParam("keyword", this.getKeyword());
return builder.toUriString();
}
UriComponentBuilder는 queryParam 메서드로 필요 파라미터를 손쉽게 추가할 수 있다.
getListLink로 BoardController의 modify와 remove를 다음처럼 간단하게 정리할 수 있다.
@PostMapping("/modify")
public String modify(BoardVO board, @ModelAttribute("cri") Criteria cri, RedirectAttributes rttr) {
log.info("modify: " + board);
if(service.modify(board)) {
rttr.addFlashAttribute("result", "sucess");
}
return "redirect:/board/list" + cri.getListLink();
}
@PostMapping("/remove")
public String remove(@RequestParam("bno") Long bno, @ModelAttribute("cri") Criteria cri, RedirectAttributes rttr) {
log.info("remove..." + bno);;
if(service.remove(bno))
{
rttr.addFlashAttribute("result", "success");
}
return "redirect:/board/list" + cri.getListLink();
}
'⚙️ Backend > 스프링(Spring) Framework' 카테고리의 다른 글
스프링 - 검색 페이징 처리 (0) | 2021.05.14 |
---|---|
스프링 - 페이징 화면 처리에 관하여.. (0) | 2021.05.13 |
스프링 프로젝트에 템플릿 적용시키기 (0) | 2021.05.11 |
Junit 4 & Spring Test을 이용한 TDD 환경 세팅 (0) | 2021.05.11 |
스프링 (Spring) 의존성 주입이란 ? (0) | 2021.05.10 |
댓글