🚀  SD 카드 특정 폴더의 이미지 파일을 보여주는 간단한 이미지 뷰어 앱 만들기

 

1.  화면 디자인 및 편집

 

1) MyPictureView 클래스

커스텀 위젯 (Custom Widget, Custom View)을 직접 만들어서 activity_main.xml에 넣어서 사용.
  ➡️ 커스텀 위젯은 지정된 이미지 파일을 출력하는 역할

 

MyPictureView.kt

 

    ✓  onDraw() 메서드를 오버라이딩

class MyPictureView(context: Context, attrs: AttributeSet?) : View(context, attrs) {

    var imagePath : String? = null

    @SuppressLint("DrawAllocation")
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        try {
            if (imagePath != null) {
                val bitmap = BitmapFactory.decodeFile(imagePath)
                canvas.scale(2f, 2f, 0f, 0f)
                canvas.drawBitmap(bitmap!!, 0f, 0f, null)
                bitmap.recycle()
            }
        } catch ( e : Exception) {

        }
    }
}

 

var imagePath : String? = null


    ✓  이미지 파일의 경로 및 파일 이름을 저장할 변수

 

if (imagePath != null) {
val bitmap = BitmapFactory.decodeFile(imagePath)
canvas.scale(2f, 2f, 0f, 0f)
canvas.drawBitmap(bitmap!!, 0f, 0f, null)
bitmap.recycle()
}


    ✓ imagePath에 값이 있으면(경로 및 파일이름이 지정되었다면) 화면에 그림 파일을 출력

 


 

2) activity_main.xml

  • 가로 레이아웃에 버튼 2개를 생성
  • 커스텀 위젯인 MyPictureView를 생성
  • 위젯의 이름은 btnPrev, btnNext, myPictureView
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"
    android:orientation="vertical">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal" >

        <Button
            android:id="@+id/btnPrev"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="이전 그림" />

        <Button
            android:id="@+id/btnNext"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="다음 그림" />

    </LinearLayout>

    <kr.abc.stream_practice.MyPictureView
        android:id="@+id/myPictureView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</LinearLayout>

 

 

👾  /storage/emulated/0/Pictures 에 이미지 업로드 후 AndroidManifest.xml 에 권한 설정

 <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <application
        android:requestLegacyExternalStorage="true"

 


 

2.  kotlin 코드 작성 및 수정

class MainActivity : AppCompatActivity() {
    // 전역변수 선언
    lateinit var btnPrev: Button
    lateinit var btnNext: Button
    lateinit var myPicture: MyPictureView
    
    var curIndex: Int = 1 // 이미지 파일의 인덱스로 사용할 변수
    var imageFiles: Array<File>? = null // SD 카드에서 읽어올 이미지 파일의 배열

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        title = "간단 이미지 뷰어"
        
        // 접근 권한 요청
        ActivityCompat.requestPermissions(
            this,
            arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE),
            Context.MODE_PRIVATE
        )

        btnPrev = findViewById(R.id.btnPrev)
        btnNext = findViewById(R.id.btnNext)
        myPicture = findViewById(R.id.myPictureView)

        imageFiles =
            File(Environment.getExternalStorageDirectory().absolutePath + "/Pictures").listFiles()

        // 파일 목록 출력
        for (i in imageFiles!!.indices) {
            var fileName = if (imageFiles!![i].isDirectory == true)
                "<폴더> " + imageFiles!![i].toString()
            else
                "<파일> " + imageFiles!![i].toString()
            println(fileName)
        }

        // 첫 번째 파일을 커스텀 위젯에 출력
        // 해당 인덱스의 이미지 파일 이름을 myPicture에 전달한다는 뜻
        myPicture.imagePath = imageFiles!![curIndex].toString()

        btnPrev.setOnClickListener {
            if (curIndex <= 1) {
                Toast.makeText(applicationContext, "첫번째 그림입니다", Toast.LENGTH_SHORT).show()
            } else {
                myPicture.imagePath = imageFiles!![--curIndex].toString()
                myPicture.invalidate()
            }
        }

        btnNext.setOnClickListener {
            if (curIndex >= imageFiles!!.size-1) {
                Toast.makeText(applicationContext, "마지막 그림입니다.", Toast.LENGTH_SHORT).show()
            } else {
                myPicture.imagePath = imageFiles!![++curIndex].toString()
                myPicture.invalidate()
            }
        }
    }
}

 

 


첫 번째 이미지에서 이전 버튼을 누르면 마지막 이미지가 뜨거나,
마지막 이미지에서 다음 버튼을 누르면 첫 번째 이미지가 뜨게 하기
class MainActivity : AppCompatActivity() {
    lateinit var btnPrev: Button
    lateinit var btnNext: Button
    lateinit var textIndex: TextView
    lateinit var textTotal: TextView
    lateinit var myPicture: MyPictureView
    var curIndex: Int = 1
    var imageFiles: Array<File>? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        title = "간단 이미지 뷰어"
        ActivityCompat.requestPermissions(
            this,
            arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE),
            Context.MODE_PRIVATE
        )

        btnPrev = findViewById(R.id.btnPrev)
        btnNext = findViewById(R.id.btnNext)

        textIndex = findViewById(R.id.textIndex)
        textTotal = findViewById(R.id.textTotal)

        myPicture = findViewById(R.id.myPictureView)

        imageFiles =
            File(Environment.getExternalStorageDirectory().absolutePath + "/Pictures").listFiles()

        for (i in imageFiles!!.indices) {
            var fileName = if (imageFiles!![i].isDirectory == true)
                "<폴더> " + imageFiles!![i].toString()
            else
                "<파일> " + imageFiles!![i].toString()
            println(fileName)
        }

        myPicture.imagePath = imageFiles!![curIndex].toString()
        textTotal.text = (imageFiles!!.size-1).toString()
        textIndex.text = curIndex.toString()

        btnPrev.setOnClickListener {

            if (curIndex == 1) {
                curIndex = imageFiles!!.size
            } else {
                myPicture.imagePath = imageFiles!![--curIndex].toString()
                textIndex.text = curIndex.toString()
                myPicture.invalidate()
            }

        }

        btnNext.setOnClickListener {

            if (curIndex >= imageFiles!!.size-1) {
               curIndex = 0
            } else {
                myPicture.imagePath = imageFiles!![++curIndex].toString()
                textIndex.text = curIndex.toString()
                myPicture.invalidate()
            }

        }

    }
}

 

 

 

 

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


8.  수정 - 제목, 내용만

  • DAO에 수정하는 updateOne() 메서드 작성
  • 테스트 코드 작성 후 테스트
  • Service 작업
  • 컨트롤러에 modify 추가
  • modify.jsp 작성
public interface BoardMapper {

    void insert(BoardVO boardVO); // DB 저장하는 메소드

    List<BoardVO> selectAll();

    BoardVO selectOne(Integer no);

    void updateHit(Integer no);

    void updateOne(BoardVO boardVO);
}
    <update id="updateOne">
        UPDATE tbl_board SET title = #{title}, content= #{content} WHERE no = #{no}
    </update>
    @Test
    public void updateOne() {
        Integer no = 8;
        BoardVO boardVO = BoardVO.builder()
                .no(no)
                .title("수정")
                .content("수정된 내용...")
                .build();

        boardMapper.updateOne(boardVO);
        log.info(boardMapper.selectOne(no));
    }

public interface BoardService {

    void register(BoardDTO boardDTO);

    List<BoardDTO> getAll();

    BoardDTO getOne(Integer no);

    void updateOne(BoardDTO boardDTO);
}
    @Override
    public void updateOne(BoardDTO boardDTO) {
        log.info("update...");

        BoardVO boardVO = modelMapper.map(boardDTO, BoardVO.class);
        log.info(boardVO);
        boardMapper.updateOne(boardVO);

    }
    @PostMapping("/modify")
    public String modify(@Valid BoardDTO boardDTO,
                         BindingResult bindingResult,
                         RedirectAttributes redirectAttributes) {

        if (bindingResult.hasErrors()) {
            // 유효성 검사 결과 에러가 있으면 수정 페이지로 돌아감
            log.info("has error");

            redirectAttributes.addFlashAttribute("errors", bindingResult.getAllErrors());
            redirectAttributes.addAttribute("no", boardDTO.getNo());
            return "redirect:/board/modify";
        }
        boardService.updateOne(boardDTO);
        return "redirect:/board/read?no=" + boardDTO.getNo();
    }
<div class="card-body">
    <form action="/board/modify" method="post">
    <input type="hidden" name="no" value="${dto.no}">
    <br>
    <div class="input-group">
        <span class="input-group-text">Title</span>
            <input type="text" name="title" class="form-control" value="${dto.title}">
    </div><br>
    <div class="input-group">
        <span class="input-group-text">Content</span>
        <textarea name="content" class="form-control">${dto.content}</textarea>
    </div><br>
    <div class="input-group">
        <span class="input-group-text">Writer</span>
        <input type="text" name="writer" class="form-control" value="${dto.writer}" readonly>
    </div><br>
    <div class="input-group">
        <span class="input-group-text">Date</span>
        <input type="date" name="dueDate" class="form-control" value="${dto.addDate}" readonly>
    </div><br>
    <div class="my-4">
        <div class="float-end">
            <button type="button" class="btn btn-danger">Remove</button>
            <button type="button" class="btn btn-primary">Modify</button>
            <button type="button" class="btn btn-secondary">List</button>
        </div>
    </div>
    </form>
    
    <script>
        const serverValidResult = {};
        <c:forEach items="${errors}" var="error">
            serverValidResult['${error.getField()}'] = '${error.defaultMessage}';
        </c:forEach>
        console.log(serverValidResult);
    </script>
                    
    <script>
        const frmModify = document.querySelector('form');
        document.querySelector('.btn-danger').addEventListener('click', function () {
            frmModify.action = '/board/remove';
            frmModify.method = 'post';
            frmModify.submit();
        });
        document.querySelector('.btn-primary').addEventListener('click', function () {
            frmModify.action = '/board/modify';
            frmModify.method = 'post';
            frmModify.submit();
        });
        document.querySelector('.btn-secondary').addEventListener('click', function (e) {
            self.location = `/board/list?${boardDTO.link}`;
        });
    </script>
</div>

 


 

9.  삭제

  • DAO에 삭제하는 deleteOne() 메서드 작성
  • 테스트 코드 작성 후 테스트
  • Service 작업
  • 컨트롤러에 remove 추가
public interface BoardMapper {

    void insert(BoardVO boardVO); // DB 저장하는 메소드

    List<BoardVO> selectAll();

    BoardVO selectOne(Integer no);

    void updateHit(Integer no);

    void updateOne(BoardVO boardVO);

    void deleteOne(Integer no);
}
    <delete id="deleteOne">
        DELETE FROM tbl_board WHERE no = #{no}
    </delete>
    @Test
    public void deleteOne() {
        Integer no = 8;
        BoardVO boardVO = boardMapper.selectOne(no);
        log.info(boardVO);

        boardMapper.deleteOne(no); // 삭제

        boardVO = boardMapper.selectOne(no);
        log.info(boardVO);
    }

public interface BoardService {

    void register(BoardDTO boardDTO);

    List<BoardDTO> getAll();

    BoardDTO getOne(Integer no);

    void updateOne(BoardDTO boardDTO);

    void deleteOne(Integer no);

}
    @Override
    public void deleteOne(Integer no) {
        boardMapper.deleteOne(no);
    }
    @PostMapping("/remove")
    public String remove(Integer no, RedirectAttributes redirectAttributes) {
        log.info("-----remove----");
        log.info("no: " + no);

        boardService.deleteOne(no);
        return "redirect:/board/list";
    }

 

 


 

10.  비밀번호 확인

  • PasswdVO 작성
  • BoardMapper 인터페이스 작성
  • BoardMapperxml 작성
  • BoardMapperTest 클래스 작성
  • PasswdDTO 작성
  • BoardService 작성
  • BoardServiceImpl 작성
@Getter
@ToString
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class PasswdVO {
    private int no;
    private String passwd;
}
public interface BoardMapper {

    ...

    BoardVO selectOneByPasswd(PasswdVO passwdVO);
}
    <select id="selectOneByPasswd" resultType="com.example.spring_ex_01_2404.domain.BoardVO">
        SELECT * FROM tbl_board WHERE no = #{no} AND passwd = SHA2(#{passwd}, 256)
    </select>
    @Test
    public void selectOneByPasswd() {
        PasswdVO passwdVO = PasswdVO.builder()
                .no(12)
                .passwd("1234").build();
        BoardVO boardVO = boardMapper.selectOneByPasswd(passwdVO);

        log.info(boardVO);
    }

@ToString
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PasswdDTO {
    private Integer no;
    private String passwd;
}
public interface BoardService {
    ...
    
    boolean isCurrentPasswd(PasswdDTO passwdDTO);
}
 @Override
    public boolean isCurrentPasswd(PasswdDTO passwdDTO) {
        PasswdVO passwdVO = modelMapper.map(passwdDTO, PasswdVO.class);

        return boardMapper.selectOneByPasswd(passwdVO) != null;
    }
    @Test
    public void isCurrentPasswd() {
        PasswdDTO passwdDTO = PasswdDTO.builder()
                .no(12)
                .passwd("1234").build();
        log.info(boardService.isCurrentPasswd(passwdDTO));
    }

 

JSP 코드 - 수정 or 삭제에 따라 버튼 내용 및 문구 타이틀 변경 추가
 <div class="card-body">
    <h3>${reason == "incorrect" ? "틀린 비밀번호 입니다." : "비밀번호를 입력해주세요!"}</h3>
    <form action="/board/passwd" method="post">
        <input type="hidden" name="no" value="${no}">
        <input type="hidden" name="mode" value="${mode}">
        <div class="input-group">
            <span class="input-group-text">Password</span>
            <input type="password" name="passwd" placeholder="비밀번호">
        </div><br>
        <div class="my-4">
            <div class="float-end">
                <button type="submit" class="btn btn-danger">${mode == "remove" ? "삭제" : "수정"}</button>
            </div>
        </div>
    </form>
</div>

 

Controller - 수정, 삭제 하기 전 비밀번호 확인 코드
    @GetMapping("/modify")
    public String modify(Integer no, Model model,
                       HttpServletRequest request,
                       RedirectAttributes redirectAttributes) {

        // 1. 세션에 저장된 비밀번호를 불러옴.
        HttpSession session = request.getSession();
        String passwd = (String) session.getAttribute("passwd");

        // 2. 비밀번호가 없으면 비밀번호 입력 페이지로 리다이렉트
        if (passwd == null || passwd.isEmpty()) {
            redirectAttributes.addAttribute("no", no);
            redirectAttributes.addAttribute("mode", "modify");
            return "redirect:/board/passwd";
        }

        // 3. 비밀번호가 틀리면 비밀번호 입력 페이지로 리다이렉트
        if (!boardService.isCurrentPasswd(PasswdDTO.builder().no(no).passwd(passwd).build())) {
            session.removeAttribute("passwd");
            redirectAttributes.addAttribute("no", no);
            redirectAttributes.addAttribute("mode", "modify");
            redirectAttributes.addFlashAttribute("reason", "incorrect");
            return "redirect:/board/passwd";
        }

        BoardDTO boardDTO = boardService.getOne(no);
        model.addAttribute("dto", boardDTO);
        
        return "/board/modify";
    }

    @RequestMapping("/remove")
    public String remove(Integer no, HttpServletRequest request, RedirectAttributes redirectAttributes) {

        // 1. 세션에 저장된 비밀번호를 불러옴.
        HttpSession session = request.getSession();
        String passwd = (String) session.getAttribute("passwd");

        // 2. 비밀번호가 없으면 비밀번호 입력 페이지로 리다이렉트
        if (passwd == null || passwd.isEmpty()) {
            redirectAttributes.addAttribute("no", no);
            redirectAttributes.addAttribute("mode", "remove");
            return "redirect:/board/passwd";
        }

        // 3. 비밀번호가 틀리면 비밀번호 입력 페이지로 리다이렉트
        if (!boardService.isCurrentPasswd(PasswdDTO.builder().no(no).passwd(passwd).build())) {
            session.removeAttribute("passwd");
            redirectAttributes.addAttribute("no", no);
            redirectAttributes.addAttribute("mode", "remove");
            redirectAttributes.addFlashAttribute("reason", "incorrect");
            
            return "redirect:/board/passwd";

        }

        // 4. 게시물 삭제
        log.info("-----remove----");
        boardService.deleteOne(no);

        // 5. 삭제 후 비밀번호 삭제
        session.removeAttribute("passwd");

        return "redirect:/board/list";
    }

    // 비밀번호 입력 페이지
    @GetMapping("/passwd")
    public void passwd(Integer no, String mode, Model model) {
        model.addAttribute("no", no);
        model.addAttribute("mode", mode);
    }

    @PostMapping("/passwd")
    public String passwdPost(Integer no, String mode, String passwd, HttpServletRequest request,
                             RedirectAttributes redirectAttributes) {
        log.info("...passwdPost()");
        log.info(mode);

        // 1. 전달받은 비밀번호를 세션에 저장
        HttpSession session = request.getSession();
        session.setAttribute("passwd", passwd);

        // 2. 해당 처리 페이지로 리다이렉트
        redirectAttributes.addAttribute("no", no);
        return "redirect:/board/" + mode;
    }

 

 

 

 

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]

 


1.  activity_main.xml

데이터 피커, 에디트텍스트, 버튼을 1개씩 생성

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"
    android:orientation="vertical">

    <DatePicker
        android:id="@+id/datePicker"
        android:datePickerMode="spinner"
        android:calendarViewShown="false"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <EditText
        android:id="@+id/editText"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:padding="10dp"
        android:background="#F0FFE6"
        android:layout_weight="1"
        android:lines="8"/>

    <Button
        android:id="@+id/btn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:enabled="false"
        android:text="BUTTON" />

</LinearLayout>

 

android:lines="8"

 

    ✓  에디트텍스트를 8행으로 설정

 

android:enabled="false"


    ✓  초기에 버튼을 disable 되게 하고, java 코드에서 enable 되게 함

 


 

2.  Kotlin 코드 작성 및 수정

  • activity_main의 3개 위젯에 대응할 위젯 변수 3개를 선언
  • 파일 이름을 저장할 문자열 변수 1개를 선언. 파일 이름을 '연_월_일.txt'로 지정
  • 위젯 변수에 activity_main.xml의 위젯을 대입
  • 데이트피커를 설정
  • Calendar 클래스를 이용하여 현재 날짜의 년, 월, 일을 구한 후 데이트피커를 초기화 ➡️ 데이트피커의 날짜가 변경되면 변경된 날짜에 해당하는 일기 파일(연_월_일.txt)의 내용을 에디트텍스트로 보여줌
  • 현재 날짜의 파일(연_월_일.txt)을 읽어 일기의 내용을 반환하는 readDiary() 메서드 완성
class MainActivity : AppCompatActivity() {
    lateinit var datePicker: DatePicker
    lateinit var editText: EditText
    lateinit var btn: Button
    lateinit var fileName: String // 파일 이름 저장

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        title = "간단 일기장"

        datePicker = findViewById(R.id.datePicker)
        editText = findViewById(R.id.editText)
        btn = findViewById(R.id.btn)

        val calendar = Calendar.getInstance()
        val calendarYear = calendar.get(Calendar.YEAR)
        val calendarMonth = calendar.get(Calendar.MONTH)
        val calendarDay = calendar.get(Calendar.DAY_OF_MONTH)

        fileName = "${calendarYear}_${calendarMonth + 1}_${calendarDay}.txt"
        val str = readDiary(fileName) // 날짜에 해당하는 일기 파일을 읽기
        editText.setText(str) // 에디트텍스트에 일기 내용을 출력
        btn.isEnabled = true // 버튼 활성화


        datePicker.init(
            calendarYear,
            calendarMonth,
            calendarDay,
            DatePicker.OnDateChangedListener() { datePicker: DatePicker, year: Int, month: Int, day: Int ->
                fileName = "${year}_${month + 1}_${day}.txt"
                val string = readDiary(fileName) // 날짜에 해당하는 파일을 읽기
                editText.setText(string) // 에디트텍스트에 일기 내용을 출력
                Toast.makeText(applicationContext, fileName, Toast.LENGTH_SHORT).show()
                btn.isEnabled = true // 버튼 활성화
            })

        btn.setOnClickListener {
            val outputStream = openFileOutput(fileName, Context.MODE_PRIVATE)
            val string = editText.text.toString()
            outputStream.write(string.toByteArray())
            outputStream.close()
            Toast.makeText(applicationContext, "${fileName} 이 저장됨", Toast.LENGTH_SHORT).show()
        }
    }

    private fun readDiary(fileName: String): String? {
        var diaryStr: String? = null
        val inputStream: FileInputStream

        try {
            inputStream = openFileInput(fileName)
            val txt = ByteArray(inputStream.available())
            inputStream.read(txt)
            inputStream.close()
            diaryStr = txt.toString(Charsets.UTF_8).trim()
            btn.text = "수정하기"
        } catch (e: IOException) {
            editText.hint = "일기 없음"
            btn.text = "새로 저장"
        }
        return diaryStr;
    }

}

 

inputStream = openFileInput(fileName)


    ✓ 일기 파일을 열어 입력 파일 스트림에 저장. 파일이 없으면 예외가 발생해서 catch 구문이 실행

 


 

3.   프로젝트 실행 및 결과 확인

선택된 날짜에 쓴 일기가 있다면 일기 내용이 보이고 버튼이 <수정하기>로 바뀜
선택된 날짜에 일기가 없다면 에디트텍스트에 '일기 없음' 힌트가 보이고 버튼이 <새로 저장>으로 바뀜

 

 

 


4. 파일 확인

AVD가 켜진 상태에서 Divice File Explorer 실행
View  ▶️  Tool Windows  ▶️  Divice File Explorer

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  검색 조건을 위한 화면 처리

검색 기능은 /WEB-INF/view/todo/list.jsp에서 이루어지므로 list.jsp에 검색 관련된 화면을 작성하기 위해서 <div class="card">를 하나 추가하고 검색에 필요한 내용들을 담을 수 있도록 구성.

 <div class="row content">
    <div class="col">
        <div class="card">
            <div class="card-body">
                <h5 class="card-title">Search </h5>
                <form action="/todo/list" method="get">
                    <input type="hidden" name="size" value="${pageRequestDTO.size}">
                    <div class="mb-3">
                        <input type="checkbox" name="finished">완료여부
                    </div>
                    <div class="mb-3">
                        <input type="checkbox" name="types" value="t">제목
                        <input type="checkbox" name="types" value="w">작성자
                        <input type="text"  name="keyword" class="form-control">
                    </div>
                    <div class="input-group mb-3 dueDateDiv">
                        <input type="date" name="from" class="form-control">
                        <input type="date" name="to" class="form-control">
                    </div>
                    <div class="input-group mb-3">
                        <div class="float-end">
                             <button class="btn btn-primary" type="submit">Search</button>
                             <button class="btn btn-info clearBtn" type="reset">Clear</button>
                        </div>
                    </div>
                </form>
            </div>
        </div>
    </div>
</div>

 

 

  ✓  화면에는 <form> 태그를 이용해서 검색 조건을 전송할 수 있도록 구성
  ✓  검색을 하는 경우 무조건 페이지는 1페이지가 되므로 별도의 파라미터를 지정하지 않음
 

TodoController에서는 log.info()를 이용해서 파라미터가 정상적으로 수집되는지 확인

 

  ✓  브라우저의 주소창에는 모든 검색 조건이 GET 방식의 쿼리 스트링으로 만들어짐


 

1) 화면에 검색 조건 표시하기


검색이 처리되기는 하지만 PageRequestDTO의 정보를 EL로 처리하지 않았기 때문에 검색 후에는 검색 부분이 초기화되는 문제가 있음
  ➡️  다른 페이지로 이동을 하면 검색값이 유지가 안됨
작성된 <div>에 EL을 적용할 때 가장 문제가 되는 부분은 제목 title, 작성자 writer를 배열로 처리하고 있으므로 화면에서 처리 할 때 좀 더 편하게 사용하기 위하여 PageRequestDTO에 별도의 메서드를 구성하도록 함

 public boolean checkType(String type) {
        if(this.types == null || this.types.length == 0) {
            return false;
        }
        return Arrays.asList(this.types).contains(type);
    }

 

 

list.jsp 코드 수정
 <form action="/todo/list" method="get">
    <input type="hidden" name="size" value="${pageRequestDTO.size}">
        <div class="mb-3">
            <input type="checkbox" name="finished"${pageRequestDTO.finished ? " checked" : ""}>완료여부
        </div>
        <div class="mb-3">
            <input type="checkbox" name="types" value="t"${pageRequestDTO.checkType("t") ? " checked" : ""}>제목
            <input type="checkbox" name="types" value="w"${pageRequestDTO.checkType("w") ? " checked" : ""}>작성자
            <input type="text"  name="keyword" class="form-control" value="${pageRequestDTO.keyword}">
        </div>
        <div class="input-group mb-3 dueDateDiv">
            <input type="date" name="from" class="form-control" value="${pageRequestDTO.from}">
            <input type="date" name="to" class="form-control" value="${pageRequestDTO.to}">
        </div>
        <div class="input-group mb-3">
            <div class="float-end">
                <button class="btn btn-primary" type="submit">Search</button>
                <button class="btn btn-info clearBtn" type="reset">Clear</button>
            </div>
        </div>    
</form>


 

2) 검색 조건 초기화 시키기


검색 영역에 Clear 버튼을 누르면 모든 검색 조건을 무효화 시키도록 '/todo/list'를 호출하도록 수정

 

화면의 버튼에 'clearBtn'이라는 class 속성을 추가
 <div class="input-group mb-3">
    <div class="float-end">
        <button class="btn btn-primary" type="submit">Search</button>
        <button class="btn btn-info clearBtn" type="reset">Clear</button>
    </div>
</div>
<script>
document.querySelector('.clearBtn').addEventListener('click', function (e) {
    self.location = '/todo/list';
});
</script>

 


 

3) 조회를 위한 링크 처리


검색 기능이 추가되면 문제 되는 것은 조회나 수정 화면에 있는 'List' 버튼. 기존과 달리 검색 조건들을 그대로 유지해야 하므로 상당히 복잡한 처리가 필요. 하지만 PageRequestDTO의 getLink()를 이용하면 상대적으로 간단히 처리 가능

 

getLink()를 통해서 생성되는 링크에서 검색 조건등을 반영해 주도록 수정
public String getLink() {
            StringBuilder builder = new StringBuilder();
            builder.append("page=" + this.page);
            builder.append("&size=" + this.size);
            if(this.finished){
                builder.append("&finished=on");
            }

            if(this.types != null && this.types.length > 0){
                for (int i = 0; i < this.types.length; i++) {
                    builder.append("&types=" + types[i]);
                }
            }

            if(this.keyword != null){
                try {
                    builder.append("&keyword=" + URLEncoder.encode(keyword, "UTF-8"));
                } catch (UnsupportedEncodingException e) {
                    e.printStackTrace();
                }
            }

            if (this.from != null) {
                builder.append("&from=" + from.toString());
            }

            if (this.to != null) {
                builder.append("&to=" + to.toString());
            }

            return builder.toString();
    }

 

getLink()는 모든 검색 / 필터링 조건을 쿼리 스트링으로 구성해야 한다. 그렇지 않으면 화면에서 모든 링크를 수정해야 하기 때문에 더 복잡하게 되기 때문. 주의할 부분은 한글이 가능한 keyword 부분은 URLEncoder를 이용해서 링크로 처리할 수 있도록 처리해야 됨

 

getLink()가 수정되면 화면에서 링크가 반영되는것 확인
목록 -> 상세 -> 수정 페이지 이동시에 쿼리 스트링이 반영되는 것 확인


 

4)  페이지 이동 링크 처리


페이지 이동에도 검색 / 필터링 조건은 필요하므로 자바스크립트로 동작하는 부분을 수정해야 함
기존에는 자바 스크립트에서 직접 쿼리 스트링을 추가해서 구성했지만, 검색 / 필터링 부분에 name이 page인 부분만 추가해서 <form> 태그를 submit으로 처리해 주면 검색 / 필터링 조건을 유지하면서도 페이지 번호만 변경하는 것이 가능

  document.querySelector(`.pagination`).addEventListener('click', function (e) {
                                    e.preventDefault();
                                    e.stopPropagation(); //이벤트가 상위 엘리먼트에 전달되지 않게 막아 준다

                                    const target = e.target;
                                    if(target.tagName !== 'A') {
                                        return;
                                    }
                                    const num = target.getAttribute('data-num');
                                    const frmPage = document.querySelector('form');
                                    frmPage.innerHTML += `<input type="hidden" name="page" value="\${num}">`;
                                    frmPage.submit();

                                    //self.location = `/todo/list?page=\${num}`;
                                });

 

검색/ 필터링을 유지하면서 페이지 이동하는 것을 확인

 


 

5) 수정 화면에서의 링크 처리


수정 화면인 modify.jsp에는 Remove, Modify, List 버튼이 존재하고 각 버튼에 대한 클릭 이벤트 수정

 

A. List 버튼 처리


PageRequestDTO의 getLink()를 이용해서 처리

document.querySelector('.btn-secondary').addEventListener('click', function (e) {
    self.location = `/todo/list?${pageRequestDTO.link}`;
});

 

B. Remove 버튼 처리


삭제된 후에 1페이지로 이동해도 문제가 되지 않지만 삭제 후에도 기존 페이지와 검색 / 필터링 정보를 유지하고 싶다면 PageRequestDTO를 이용할 수 있음

const frmModify = document.querySelector('form');
document.querySelector('.btn-danger').addEventListener('click', function () {
    frmModify.action = '/todo/remove';
    frmModify.method = 'post';
    frmModify.submit();
});

 

TodoController에서는 remove() 메서드가 이미 PageRequestDTO를 파라미터로 받고 있기 때문에 삭제 처리를 하고나서 리다이렉트하는 경로에 getLink()의 결과를 반영하도록 수정

  @PostMapping("/remove")
    public String remove(Long tno, PageRequestDTO pageRequestDTO , RedirectAttributes redirectAttributes) {
        log.info("-----remove----");
        log.info("tno: " + tno);

        todoService.remove(tno);
        redirectAttributes.addAttribute("page", 1);
        redirectAttributes.addAttribute("size", pageRequestDTO.getSize());
        return "redirect:/todo/list" + pageRequestDTO.getLink();
    }

 

 

C. Modify 버튼 처리


기존과 다른 처리가 필요한데, 검색 / 필터링 기능이 추가되면 Todo의 내용이 수정되면서 검색 / 필터링 조건에 맞지 않게 될 수 있기 때문.
  ➡️  예를 들어 검색 / 필터링에 날짜로 검색했는데 날짜를 수정하면서 검색 / 필터링 조건에 맞지 않아서 목록에 나오지 않을 수 있음

 

따라서 안전하게 하려면 검색 / 필터링의 경우 수정한 후에 조회 페이지로 이동하게 하고, 검색 / 필터링 조건은 없애는 것이 안전
검색 / 필터링 조건을 유지하지 않는다면 modify.jsp 에 선언된 <input type="hidden"> 태그의 내용은 필요하지 않으므로 삭제

 <form action="/todo/modify" method="post">
    <%-- <input type="hidden" name="page" value="${pageRequestDTO.page}">
         <input type="hidden" name="size" value="${pageRequestDTO.size}"> --%>
         <div class="input-group mb-3">
             <span class="input-group-text">Tno</span>
             <input type="text" name="tno" class="form-control" value="${dto.tno}" readonly>
         </div>

 

TodoController에서는 '/todo/list'가 아닌 '/todo/read'로 이동하도록 수정
  @PostMapping("/modify")
    public String midify(PageRequestDTO pageRequestDTO,
                         @Valid TodoDTO todoDTO,
                         BindingResult bindingResult,
                         RedirectAttributes redirectAttributes) {

        // 유효성 검사를 통과하지 못했더라도 값을 전송
        //redirectAttributes.addAttribute("page", pageRequestDTO.getPage());
        //redirectAttributes.addAttribute("size", pageRequestDTO.getSize());

        if (bindingResult.hasErrors()) {
            log.info("has error");
            redirectAttributes.addFlashAttribute("errors", bindingResult.getAllErrors());
            redirectAttributes.addAttribute("tno", todoDTO.getTno());
            return "redirect:/todo/modify";
        }
        redirectAttributes.addAttribute("tno", todoDTO.getTno());
        return "redirect:/todo/read";
    }

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  날짜와 시간 관련 위젯

날짜와 시간과 관련된 위젯으로는 타임피커, 데이트피커, 캘린더뷰, 크로노미터, 아날로그시계, 디지털시계 등이 있음

 

1)  아날로그시계와 디지털시계

 

🤓  아날로그 시계 AnalogClock와 디지털시계 DigitalClock는 화면에 시간을 표시하는 위젯으로 시계를 표현하는 용도로 쓰임. 이 둘은 View 클래스에서 상속받기 때문에 background 속성들을 설정할 수 있음. 디지털 시계는 textColor 같은 속성도 설정할 수 있음.

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <AnalogClock
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <DigitalClock
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center" />

</LinearLayout>

 


2)  크로노미터

 

크로노미터 Chronometer는 타이머 형식의 위젯이며 일반적으로 시간을 측정할 때 많이 사용
사용되는 메서드로는 start(), stop(), reset() 등이 있는데 이는 각각 크로노미터를 시작, 정지, 초기화.

 

android:format="시간 측정 : %s"
  ➡️ format 속성에서 타이머 앞의 문자열을 지정
<Chronometer
        android:id="@+id/chronometer1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:format="시간 측정 : %s"
        android:gravity="center"
        android:textSize="30dp" />

 


3) 타임피커, 데이트피커, 캘린더뷰


타임피커 TimePicker는 시간을, 데이트피커 DatePicker와 캘린더뷰 CalendarView는 날짜를 표시하고 조절하는 기능을

    ⚡️  캘린더뷰는 XML 속성이 여러가지인데, 그중에서 디폴트가 true인 showWeekNumber 속성은 현재 몇 주 차인지를 각주
의 맨 앞에 출력. 하지만 showWeekNumber 속성은 false로 하는 것이 더 깔끔하고 보기 좋음

롤리팝 API21 이후부터 타임피커와 데이트피커의 모양이 대폭 변경

 

  📍  이전 모양의 버전을 사용하려면
        타임피커는 android:timePickerMode="spinner" 속성을,
        데이트피커는 android:datePickerMode="spinner" 속성을 추가

    <TimePicker
        android:timePickerMode="spinner"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <DatePicker
        android:datePickerMode="spinner"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />


2.  기타 위젯

1)  자동완성텍스트뷰와 멀티자동완성텍스트뷰

 

자동완성텍스트뷰 AutoCompleteTextView와 멀티자동완성텍스트뷰 MultiAutoCompleteTextView는 텍스트뷰 보다는 에디트텍스트 속성이 더 강함.  사용자가 단어의 일부만 입력해도 자동 완성되는데, 자동완성텍스트뷰는 단어 1개가 자동 완성되고, 멀티자동완성텍스트뷰는 쉼표(,)로 구분하여 여러 개의 단어가 자동 완성.


  ✓  자동 완성 단어는 주로 Java 코드에서 배열로 설정하며 setAdapter() 메서드를 사용
  ✓  completionHint : XML 속성 줌 목록에 나타날 힌트

    completionThreshold : 자동 완성 시작되기 전에 사용자가입력해야 하는 최소 문자 수

   <AutoCompleteTextView
        android:id="@+id/autoCompleteTextView1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:completionHint="선택하세요"
        android:completionThreshold="2"
        android:hint="자동완성텍스트뷰">
    </AutoCompleteTextView>

    <MultiAutoCompleteTextView
        android:id="@+id/multiAutoCompleteTextView1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:completionHint="선택하세요"
        android:completionThreshold="2"
        android:hint="멀티자동완성텍스트뷰">
    </MultiAutoCompleteTextView>
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.layout_04)

        // 자동완성텍스트뷰의 Java코드
        val items = arrayOf("CSI-뉴욕", "CSI-라스베가스", "CSI-마이애미", "Friend", "Fringe", "Lost")
        val auto = findViewById<AutoCompleteTextView>(R.id.autoCompleteTextView1)
        val adapter = ArrayAdapter<String>(this, android.R.layout.simple_dropdown_item_1line, items)
        auto.setAdapter(adapter)

        val multi = findViewById<MultiAutoCompleteTextView>(R.id.multiAutoCompleteTextView1)
        val tokenizer = MultiAutoCompleteTextView.CommaTokenizer()
        multi.setTokenizer(tokenizer)
        multi.setAdapter(adapter)

    }
}

 

val items = arrayOf("CSI-뉴욕", "CSI-라스베가스", "CSI-마이애미", "Friend", "Fringe", "Lost")

     > 자동 완성될 문자열을 배열로 정의

val adapter = ArrayAdapter<String>(this, android.R.layout.simple_dropdown_item_1line, items)

    > ArrayAdapter는 뷰와 데이터를 연결

    > 자동완성텍스트뷰와 items를 연결하는 역할을 하여 자동완성텍스트뷰에 items 배열의 내용이 출력
    > 생성자의 두 번째 파라미터는 목록이 출력될 모양을 결정. simple_dropdown_item_1line 외에도 다양한 모양을 선택할 수 있음

val tokenizer = MultiAutoCompleteTextView.CommaTokenizer()
multi.setTokenizer(tokenizer)
multi.setAdapter(adapter)

    > 쉼표로 구분하기 위한 객체를 생성하고 멀티자동완성텍스트 뷰에 설정

 

 


 

2) 프로그레스바, 시크바, 레이팅바

 

프로그레스바, 시크바, 레이팅바는 진행 상태를 표시하는 기능

 

 프로그레스바 ProgressBar

 

  🚀  작업의 진행 상태를 바 Bar 또는 원 형태로 제공
  🚀  바 형태는 어느 정도 진행되었는지를 확인할 수 있지만, 원 형태는 현재 진행 중이라는 상태만 보여줌
  🚀  주로 사용되는 XML 속성에는 범위를 지정하는 max, 시작 지점을 지정하는 progress,
         두 번째 프로그레스바를 지정하는 secondaryProgress 등이 있음

 

시크바 SeekBar


  🚀  프로그레스바의 하위 클래스로, 프로그레스바와 대부분 비슷하며 사용자가 터치로 임의 조절이 가능
  🚀  음량을 조정하거나 동영상 재생 시 사용자가 재생 위치를 지정하는 용도로 사용할 수 있음

 

레이팅바 RatingBar


  🚀  진행 상태를 별 모양으로 표시. 프로그레스바의 하위 클래스이므로 사용법이 비슷하며 서적, 음악, 영화 등에 대한 선호도를 나타낼 때 주로 사용
  🚀  별의 개수를 정하는 numStars, 초깃값을 지정하는 rating, 한 번에 채워지는 개수를 정하는 stepSize 속성이 주로 사용

 

   <ProgressBar style="?android:attr/progressBarStyleHorizontal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="20dp"
        android:max="100"
        android:progress="20"
        android:secondaryProgress="50" />

    <SeekBar
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="20dp"
        android:progress="20" />

    <RatingBar
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"    
        android:layout_margin="20dp"
        android:numStars="5"
        android:rating="1.5"
        android:stepSize="0.5" />

 

style="?android:attr/progressBarStyleHorizontal"
> 프로그레스바의 모양을 지정. 이 스타일을 생략하면 원 모양이 나타남

    

android:max="100"
android:progress="20"
 > 프로그레스바의 범위를 최대 100으로 설정하고, 초깃값을 20으로 설정.

   

android:secondaryProgress="50"
> 프로그레스바의 보조 프로세스 초깃값을 50으로 설정. 메인 프로그레스보다 흐리게 표시.

    

android:numStars="5"
> 별의 개수를 설정. 디폴트는 5개.

 

android:rating="1.5"
> 초깃값을 설정. 1.5개가 채워진 상태로 표현.

 

android:stepSize="0.5"
> 한 스탭의 크기를 지정. 이 경우에는 별이 5개이므로 총 열 번의 스탭으로 구성.

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  검색 / 필터링 조건의 정의

📍  대부분의 서비스에서는 검색 기능을 제공. 단순히 제목이나 내용 등을 검색하는 경우도 있고, 복잡한 검색 조건을 필터링 filtering 하는 경우도 있음
📍  검색 search는 'A 혹은 B 혹은 C'와 같이 찾고자 하는 경우 검색 조건들은 주로 OR 조건으로 연결되는 경우가 많음
        ➡️  예를 들어 '제목 or 내용 or 작성자'가 xxx인 경우와 같은 데이터가 있을수도 있고, 없을 수도 있음
📍  필터링 filtering'A인 동시에 B에도 해당'한다는 개념. 필터링은 주로 특정한 범위나 범주의 값으로 걸러내는 방식
        ➡️  예를 들어 '완료된 일 중에서 특정 날짜까지 끝난 Todo'는 'A & B'와 같이 AND라는 개념의 필터링이 적용

 

다음과 같은 검색과 필터링 조건을 구성하고 처리

 

  • 제목 title과 작성자 writer 는 키워드 keyword를 이용하는 검색 처리
  • 완료 여부를 필터링 처리
  • 특정한 기간을 지정 from, to 한 필터링 처리
  • 검색과 필터링에 필요한 데이터는 다음과 같이 구분
    * 제목, 작성자 검색에 사용하는 문자열 - keyword
    * 완료 여부에 사용되는 boolean 타입 - finished
    * 특정 기간 검색을 위한 LocalDate 변수 2개 - from, to

 


2.  검색 / 필터링 조건의 결정

검색 기능을 개발할 때는 우선 검색 기능의 경우의 수를 구분하는 작업이 필요

  • 검색 / 필터링의 종류가 '완료 여부, 제목, 작성자, 기간'들의 조합으로 구성
  • 검색 종류를 types라고 지정해서 '제목(t), 작성자(w)'로 구분해서 검색의 실제 값은 검색 종류에 따라 키워드 keyword를 이용
  • 검색은 목록 기능에 사용하는 PageRequestDTO에 필요한 변수들을 추가해서 구성
PageRequestDTO에 types, keyword, finished, from, to 변수를 새롭게 추가
    private String[] types; // 검색 경우의 수 1) title 2) writer 3) title, writer
    private String keyword; // 검색어
    private boolean finished;
    private LocalDate from;
    private LocalDate to;

 


3.  types에 따른 동적 쿼리

Mybatis에는 실행 시에 쿼리를 만들 수 있는 여러 태그들을 제공

 

1) foreach, if

 

🤓  <foreach>는 반복 처리를 위해 제공
🤓  <foreach>의 대상은 배열이나 List, Map, Set과 같은 컬렉션 계열이나 배열을 이용

 

실습을 위해 {"t", "w"}와 같은 types를 PageRequestDTO에 설정하고 테스트를 진행
기존의 TodoMapperTests 클래스에 새로운 메서드를 추가
@Test
    public void testSelectSearch() {
        PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
                .page(1)
                .size(10)
                .types(new String[]{"t", "w"})
                .keyword("AAAA")
                .build();
        List<TodoVO> voList = todoMapper.selectList(pageRequestDTO);
        voList.forEach(vo -> log.info(vo));
    }

 

📍 TodoMapper의 selectList()는 PageRequestDTO를 파라미터로 받고 있기 때문에 변경 없이 바로 사용 가능하므로 TodoMapper.xml만 수정하면 됨

 

TodoMapper.xml에서는 <select id="selectList"> 태그에 MyBatis의 <foreach>를 적용
<select id="selectList" resultType="com.example.spring_ex_01_2404.domain.TodoVO">
    select * from tbl_todo 
    <foreach collection="types" item="type">
        #{type}
    </foreach>    
    order by tno desc limit #{skip}, #{size}
</select>

 

  👾  현재 PageRequestDTO의 types는 {"t", "w"}이므로 테스트 코드를 실행하면 다음과 같은 코드가 만들어지는 것을 확인
        (쿼리문이 아직 완성된 상태가 아니라서 에러가 발생)

 

  👾  sql 부분만 보면 다음과 같이 sql 이 실행
        select * from tbl_todo ? ? order by tno desc limit ?, ?
        ✓  't'와 'w'가 전달되었기 때문에 'from tbl_todo' 뒤에 두 개의 "?"가 생성된 것을 확인.

 

<if> 적용
<select id="selectList" resultType="com.example.spring_ex_01_2404.domain.TodoVO">
    select * from tbl_todo 
    <foreach collection="types" item="type" open="(" close=")" separator=" OR">
        <if test="type == 't'.toString()">
            title like concat('%', #{keyword}, '%')
        </if>
        <if test="type =='w'.toString()">
            writer like concat('%', #{keyword}, '%')
        </if>
    </foreach>    
    order by tno desc limit #{skip}, #{size}
</select>

 

  👾  open과 close를 이용해서 '()'와 배열을 처리하면서 중간에는 OR을 추가해서 다음과 같은 쿼리가 생성
       select * from tbl_todo (title like concat('%', ?, '%') OR

           writer like concat('%', ?, '%'))

       order by tno desc limit ?, ?


2)  <where>


📍 쿼리에 where 키워드가 빠져있음. 이것은 만일에 types가 없는 경우에는 쿼리문에 where를 생성하지 않기 위함.
📍 <where>는 태그 안쪽에서 문자열이 생성되어야만 where 키워드를 추가.
📍 이를 이용해서 types가 null이 아닌 경우를 같이 적용하면 다음과 같이 작성.

<select id="selectList" resultType="com.example.spring_ex_01_2404.domain.TodoVO">
    select * from tbl_todo 
    <where>
        <if test="types != null and types.length > 0">
            <foreach collection="types" item="type" open="(" close=")" separator=" OR">
                <if test="type == 't'.toString()">
                    title like concat('%', #{keyword}, '%')
                </if>
                <if test="type =='w'.toString()">
                    writer like concat('%', #{keyword}, '%')
                </if>
            </foreach>
        </if>
    </where>
    order by tno desc limit #{skip}, #{size}
</select>

 


3) <trim>과 완료 여부 / 만료일 필터링


완료 여부 (f)와 만료 기간 (d)에 대한 처리
  📍  완료 여부는 PageRequestDTO의 finished 변수 값이 true인 경우에만 'finished = 1'과 같은 문자열이 만들어지도록 구성
         ✓  주의점은 앞의 검색 조건(제목, 작성자)이 있는 경우에는 'and finished = 1'의 형태로 만들어져야 하고,
              그렇지 않은 경우에는 바로 'finished = 1'이 되어야 함
  📍  MyBatis의 <trim>이 이런 경우에 사용. <where>과 유사하게 동작하면서 필요한 문자열을 생성하거나 제거할 수 있음

        <if test = "finished">
            <trim prefix="and">
                finished = 1
            </trim>
        </if>
    </where>    
    order by tno desc limit #{skip}, #{size}
</select>

 

  👾  finished 값을 이용해서 SQL 문을 생성해 내는데 <trim>을 적용. prefix를 적용하게 되면 상황에 따라서 'and'가 추가.

 

테스트 코드 수정해서 확인
@Test
    public void testSelectSearch() {
        PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
                .page(1)
                .size(10)
                .types(new String[]{"t", "w"})
                .keyword("스프링")
                .finished(true)
                .build();
        List<TodoVO> voList = todoMapper.selectList(pageRequestDTO);
        voList.forEach(vo -> log.info(vo));
    }

 

같은 방식으로 만료일 dueDate 을 처리하면 다음과 같음
<if test="from != null and to != null">
    <trim prefix="and">
        dueDate between #{from} and #{to}
    </trim>
</if>

 


4) <sql> 과 <include>


MyBatis의 동적 쿼리 적용은 단순히 목록 데이터를 가져오는 부분과 전체 개수를 가져오는 부분에도 적용되어야 함
  📍전체 개수를 가져오는 TodoMapper의 getCount()에 파라미터에 PageRequestDTO 타입을 지정한 이유 역시 동적 쿼리를 적용하기 위함
  📍 MyBatis에는 <sql> 태그를 이용해서 동일한 SQL 조각을 재사용할 수 있는 방법을 제공
        ➡️  동적 쿼리 부분을 <sql>로 분리하고 동일하게 동적 쿼리가 적용될 부분은 <include>를 이용해서 작성

 

TodoMapper.xml에서 <sql id='search'>로 동적 쿼리 부분을 분리
<sql id="search">
    <where>
        <if test="types != null and types.length > 0">
            <foreach collection="types" item="type" open="(" close=")" separator=" OR">
                <if test="type == 't'.toString()">
                    title like concat('%', #{keyword}, '%')
                </if>
                <if test="type =='w'.toString()">
                    writer like concat('%', #{keyword}, '%')
                </if>
            </foreach>
        </if>
        <if test = "finished">
            <trim prefix="and">
                finished = 1
            </trim>
        </if>
        <if test="from != null and to != null">
            <trim prefix="and">
                dueDate between #{from} and #{to}
            </trim>
        </if>
    </where>
</sql>
    <select id="selectList" resultType="com.example.spring_ex_01_2404.domain.TodoVO">
        select * from tbl_todo 
        <include refid="search"></include>
        order by tno desc limit #{skip}, #{size}
    </select>

    <select id="getCount" resultType="int">
        select COUNT(tno) FROM tbl_todo
        <include refid="search"></include>
    </select>

 

테스트 코드에서는 TodoMapper의 selectList()와 getCount()를 호출해서 결과를 확인
    @Test
    public void testSelectSearch() {
        PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
                .page(1)
                .size(10)
                .types(new String[]{"t", "w"})
                .keyword("스프링")
                //.finished(true)
                .from(LocalDate.parse("2022-04-28"))
                .to(LocalDate.parse("2024-04-30"))
                .build();
        List<TodoVO> voList = todoMapper.selectList(pageRequestDTO);
        voList.forEach(vo -> log.info(vo));

        log.info(todoMapper.getCount(pageRequestDTO));
    }

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]

+ Recent posts