111일차 새프로젝트 시작

기능 티켓별로 역할 나누기로결정하였다.

일단 이번 프로젝트의 목표는 crud를 restfulapi로 해보기이다.
공지사항 파트로 시작해보기로 했다.

JQuery로 작성을 했엇으니 이번 프로젝트에서는 fetch api를 사용해보기로 했다.

공지사항의 목표
페이징 처리
detail페이지 - 이전글 보기 다음 글 보기
crud기능완성하기
reg페이지 등록
수정페이지 수정
삭제기능

1. 페이징 처리와 검색

1.1 서비스

@Override
public Map<String, Object> getNoticeList(String search, String type, Integer page) {
    Integer pageSize = 10; // 10개씩
    Integer startNum = (page - 1) * pageSize; // 0 10 20

    //페이지네이션 정보
    //총 글 개수
    Integer count = noticeMapper.countAll(type, search);

    // 마지막 페이지 번호
    // 총 글개수 -1 / pageSize + 1
    Integer lastPage = (count - 1) / pageSize + 1;

    // 페이지네이션 왼쪽번호 1 11 21 31
    Integer leftPageNum = (page - 1) / pageSize * pageSize + 1;
    leftPageNum = Math.max(leftPageNum, 1);

    // 페이지네이션 오른쪽 번호
    Integer rightPageNum = leftPageNum + 9;
    rightPageNum = Math.min(rightPageNum, lastPage);

    // 이전페이지
    Integer prevPageNum = leftPageNum - 10;
    prevPageNum = Math.max(leftPageNum - 10, 1);

    // 다음 페이지
    Integer nextPageNum = leftPageNum + 10;

    Map<String, Object> pageInfo = new HashMap<>();
    pageInfo.put("lastPageNum", lastPage);
    pageInfo.put("leftPageNum", leftPageNum);
    pageInfo.put("rightPageNum", rightPageNum);
    pageInfo.put("currentPageNum", page);
    pageInfo.put("prevPageNum", prevPageNum);
    pageInfo.put("nextPageNum", nextPageNum);

    List<Notice> noticeList = noticeMapper.getNoticeList(startNum, pageSize, search, type);
    return Map.of("pageInfo", pageInfo, "noticeList", noticeList);
}

startNum, pageSize, search, type
이전 글에서 작성했듯이 get요청에 파라미터로 담아서 값을 가져온다.
페이지 size는 10개씩 페이지 시작번호는 0 10 20 이런식으로 가기때문에 (page - 1) * pageSize으로 작성해준다.
마지막 번호는 총 글개수에서 -1 하고 페이지 사이즈로 나누고 +1 을해주면된다.
690개라면 690- 1 / 10 + 1이다.
페이지네이션 왼쪽번호와 오른쪽 번호를 넣고 보내준다.
1에서 이전으로 더가면안되고 마지막페이지보다 더가면안되니 그 번호를 처리해준다.

1.2 매퍼

검색결과가 all일때 검색 type을 지정했을때의 코드를 동적sql로 작성해준다.

<select id="getNoticeList" resultMap="noticeMap">
    <bind name="pattern" value="'%' + search + '%'"/>
    SELECT n.*, count(f.file_id) count FROM TB_NOTICE n
    LEFT JOIN TB_FILES f
    ON n.notice_id = f.notice_id
    <where>
        <if test="type eq 'all'">
            (title LIKE #{pattern} OR content LIKE #{pattern} OR member_id LIKE #{pattern})
        </if>
        <if test="type eq 'title'">
            OR title LIKE #{pattern}
        </if>
        <if test="type eq 'content'">
            OR content LIKE #{pattern}
        </if>
        <if test="type eq 'writer'">
            OR modifier_id LIKE #{pattern}
        </if>
    </where>
    GROUP BY notice_id
    ORDER BY modified DESC LIMIT #{startNum}, #{pageSize}
</select>

<select id="countAll" resultType="Integer">
    <bind name="pattern" value="'%' + search + '%'"/>
    SELECT COUNT(notice_id) FROM TB_NOTICE
    <where>
        <if test="type eq 'all'">
            (title LIKE #{pattern} OR content LIKE #{pattern} OR member_id LIKE #{pattern})
        </if>
        <if test="type eq 'title'">
            OR title LIKE #{pattern}
        </if>
        <if test="type eq 'content'">
            OR content LIKE #{pattern}
        </if>
        <if test="type eq 'writer'">
            OR modifier_id LIKE #{pattern}
        </if>
    </where>
</select>

1.3 view ajax

테이블로 값을 넣어줄 것이라 구조는 보지 않고 동적으로 데이터를 불러오게 했다.

function listView(searchValue, typeValue, pageValue) {
    const search = searchValue;
    const type = typeValue;

    const requestData = {
        search: searchValue,
        type: typeValue,
        page : pageValue
        page: pageValue
    };

    // URL 매개변수로 데이터 직렬화
    const params = new URLSearchParams(requestData).toString();

    fetch(`/customer/notice/api/list?${params}`)
    fetch(`/api/notices?${params}`)
        .then(response => response.json())
        .then(noticeList => {
            // 이하 코드는 동일합니다.
        .then(data => {
            const noticeList = data.noticeList;
            const pageInfo = data.pageInfo;
            const tbody = document.querySelector("#noticeTable tbody");
            const pageUl = document.querySelector("#page-ul");
            tbody.innerHTML = "";

            noticeList.forEach(notice => {
                const date = new Date(notice.modified);
                const options = {year: 'numeric', month: 'long', day: 'numeric'};
                const formattedDate = date.toLocaleDateString('ko-KR', options);

                const maxLength = 15; // 최대 길이 설정

                let truncatedTitle = "";

                if (notice.title.length > maxLength) {
                    truncatedTitle = notice.title.substring(0, maxLength) + '...';
                } else {
                    truncatedTitle = notice.title;
                }

                const noticeHtml = `
                    <tr>
                        <td>${notice.noticeId}</td>
                        <td><a href="#">${notice.title}</a></td>
                        <td>${notice.created}</td>
                        <td>${notice.memberId}</td>
                        <td>${notice.hit}</td>
                        <td style="text-align: center; width: 50px;">${notice.noticeId}</td>
                        <td style="text-align: left; width: 300px" ><a href="/customer/notice/${notice.noticeId}">${truncatedTitle}</a>                                </td>
                        <td style="text-align: center; width: 230px " >${notice.modifierId}</td>
                        <td style="text-align: center; width: 70px">${notice.hit}</td>
                        <td style="text-align: center; width: 150px" >${formattedDate}</td>
                    </tr>
                `;
                tbody.insertAdjacentHTML('beforeend', noticeHtml);
            });

            pageUl.innerHTML = "";
            createPagination(pageInfo, pageUl);

        })
        .catch(error => {
            console.error("Error:", error);
      searchBtn.addEventListener("click", function () {
    const type = document.querySelector("#type");
    typeValue = type.value;

    listView(searchValue, typeValue);
    pageValue = '1';

    listView(searchValue, typeValue, pageValue);
});

날짜를 DATE TIME으로 저장했기 때문에 연월일 시분초가 나오는데 연월일만 나오게 하고 싶다.
const date = new Date(notice.modified);
const options = { year: 'numeric', month: 'long', day: 'numeric' };
const formattedDate = date.toLocaleDateString('ko-KR', options);

JavaScript에서 날짜를 원하는 형식으로 포맷팅하려면 toLocaleDateString() 메소드를 사용했다.
이 메소드에 날짜 형식 옵션을 제공하여 원하는 형식으로 날짜를 표시할 수 있다.

toLocaleDateString() 메소드를 사용하여 날짜를 "YYYY년 MM월 DD일" 형식으로 표현할 수 있다.



javascript
Copy code
const date = new Date(notice.modified);
const options = { year: 'numeric', month: 'long', day: 'numeric' };
const formattedDate = date.toLocaleDateString('ko-KR', options);

console.log(formattedDate); // 출력: "2023년 7월 6일"
options 객체는 원하는 형식을 정의하는 옵션이다.
year은 연도를, month는 월을, day는 일을 의미한다.
'numeric' 옵션은 숫자로 표시하고, 'long' 옵션은 긴 형식으로 표시한다.
원하는 형식에 맞게 옵션을 조정하여 날짜를 원하는 형식으로 포맷팅할 수 있다.

options 객체에는 날짜 형식을 지정하는 속성들을 포함하고 있습니다. year, month, day 속성은 각각 연도, 월, 일에 대한 형식을 지정한다.
toLocaleDateString() 메서드는 첫 번째 매개변수로 로케일(locale)을 받는다.

로케일은 언어와 지역에 따라 날짜 형식을 조정하는 데 사용된다.
예를 들어 'ko-KR'은 한국어로 표시하고 한국 지역의 형식을 사용하도록 지정하는 것이라고 한다.
로케일을 지정하지 않거나 'ko-KR'로 설정하지 않으면 기본적으로 브라우저의 설정에 따라 형식이 결정됩니다.

따라서 위의 코드에서 toLocaleDateString('ko-KR', options)는 한국어로 표시하고 한국 지역의 형식을 사용하여 날짜를 포맷팅하는 것을 의미한다.
즉 ko-KR 로케일과 options 객체를 함께 사용하면 년, 월, 일을 나타내는 형식으로 날짜를 표시할 수 있다.

예를 들어, new Date()로 생성된 날짜가 2023년 7월 6일이라면 위의 코드를 실행하면 "2023년 7월 6일"로 포맷팅된 날짜가 반환된다.
이는 options 객체에 year: 'numeric', month: 'long', day: 'numeric' 속성을 지정했기 때문이다.

2. 디테일 페이지

2.1 컨트롤러

path파라미터로 noticeId를 받아온다.
그후 값을 Map에 담아서 ResponseEntity로 변환후 응답한다. Json으로 돌려주게 되는 것이다.

@GetMapping("{noticeId}")
  public ResponseEntity<Map<String, Object>> detail(@PathVariable("noticeId") Integer noticeId) {
      Map<String, Object> result = noticeService.getDetail(noticeId);
      return ResponseEntity.ok(result);
  }

2.2 서비스

id를 통해서 게시글을 불러온다.
이전글과 다음글을 나타내기 위해 그 값을 가져오기로 했다.
그러나 이전글과 다음글이 없다면? 맨처음글과 마지막글이기 때문에 각 값에 첫글과 마지막글을 넣어주었다.

@Override
public Map<String, Object> getDetail(Integer noticeId) {

    // 글상세
    Notice notice = noticeMapper.getDetail(noticeId);

    //이전글
    Integer prevNotice = noticeMapper.getPrevNotice(noticeId);

    if (prevNotice == null) {
        prevNotice = noticeMapper.getFirstNotice(noticeId);
    }

    //다음글
    Integer nextNotice = noticeMapper.getNextNotice(noticeId);

    if (nextNotice == null) {
        nextNotice = noticeMapper.getLastNotice(noticeId);
    }

    return Map.of("notice", notice,
            "prevNotice", prevNotice,
            "nextNotice", nextNotice
    );
}

2.3 매퍼

첨부파일이 있기 때문에 VIEW_NOTICE 일단 파일 명을 가져오는 테이블과 함께 view를 만들었다.
1노티스 파일명1
1노티스 파일명2
이런식으로 테이블이 생성된다.

 CREATE VIEW VIEW_NOTICE
 AS
 SELECT n.*, f.file_name FROM TB_NOTICE n LEFT JOIN TB_FILES f ON n.notice_id = f.notice_id;

첨부파일이 있기 때문에 파일명은 collecion으로 매핑해서 filename의 리스트로 매핑을 해준다.

 <!-- notice상세 불러오는 resultMap-->
  <resultMap type="com.project.careerfair.domain.Notice"
             id="NoticeViewMap">
      <id column="notice_id" property="noticeId"/>
      <result column="title" property="title"/>
      <result column="content" property="content"/>
      <result column="created" property="created"/>
      <result column="member_id" property="memberId"/>
      <result column="hit" property="hit"/>
      <result column="modifier_id" property="modifierId"/>
      <result column="modified" property="modified"/>
      <collection property="fileName" ofType="string">
          <result column="file_name"/>
      </collection>
  </resultMap>

<select id="getDetail" resultMap="NoticeViewMap">
    SELECT * FROM VIEW_NOTICE WHERE notice_id = #{noticeId}
</select>

<select id="getPrevNotice" resultType="Integer">
    SELECT notice_id FROM TB_NOTICE WHERE notice_id &lt; #{noticeId} ORDER BY modified DESC LIMIT 1
</select>

<select id="getNextNotice" resultType="Integer">
    SELECT notice_id FROM TB_NOTICE WHERE notice_id &gt; #{noticeId} ORDER BY modified ASC LIMIT 1
</select>

<select id="getFirstNotice" resultType="Integer">
    SELECT notice_id FROM TB_NOTICE ORDER BY notice_id ASC LIMIT 1;
</select>

<select id="getLastNotice" resultType="Integer">
    SELECT notice_id FROM TB_NOTICE ORDER BY notice_id DESC LIMIT 1;
</select>

게시글을 가져오는 쿼리는 그냥 그렇고 이전글 다음글 첫글 마지막글을 가져오도록 하자.
수정일을 기준으로 내림차순한 후 현재 글의 id보다 작은 글을 가져오면 이전글을 가져올 수 있다.
XML이기 때문에 < >와 같은 특수기호를 entity로 작성해주어야한다.
그래서 &lt;으로 작성했다.

다음글은 오름차순 정리후 그 글보다 큰것들 중하나만 가져오면 된다.
단순하게 아이디만 받아와서 그 글로 이동해주기 편하게 만들었다.

2.4 view

역시나 비동기로 값을 가져와서 매핑을 하였다.
api에서 보내준 응답을 json으로 바꾸어준다.
map과 비슷하게 생겼기때문에 각각 담았던 것들을 키로 꺼내주면된다.
list와 비슷하게 날짜를 포맷핑하는데 이번엔 시분초 까지 넣어주었다.
파일은 a태그에 값을 넣어서 반복해서 출력해주면된다.
동적으로 값이 들어가기 때문에 이전글과 다음글이벤트를 여기서 생성해주어야한다.

function detailView() {
const url = window.location.href;
const noticeId = url.substring(url.lastIndexOf("/") + 1);

fetch(`/api/notices/${noticeId}`)
    .then(response => response.json())
    .then(data => {
        const notice = data.notice;
        const prevId = data.prevNotice;
        const nextId = data.nextNotice;

        const titleInput = document.getElementById('title');
        titleInput.value = notice.title;

        const writerInput = document.getElementById('writer');
        writerInput.value = notice.modifierId;

        const createdInput = document.getElementById('created');
        const options = {
            year: 'numeric',
            month: 'long',
            day: 'numeric',
            hour: '2-digit',
            minute: '2-digit',
            second: '2-digit',
        };
        createdInput.value = new Date(notice.modified).toLocaleString('ko-KR', options);

        const contentTextarea = document.getElementById('content');
        contentTextarea.value = notice.content;

        const fileNameContainer = document.getElementById('file-name');

        if (data.notice.fileName.length === 0){
            fileNameContainer.classList.add('d-none');
        } else{
            data.notice.fileName.forEach(fileName => {
                const fileLink = document.createElement('a');
                fileLink.href = `${bucketUrl}/${noticeId}/${fileName}`;
                fileLink.textContent = fileName;
                fileLink.classList.add('form-control');
                fileLink.download = fileName;

                fileNameContainer.appendChild(fileLink);
            });
        }

        const prev = document.getElementById('prev');

        if (prevId === parseInt(noticeId)) {
            prev.addEventListener("click", function() {
                alert("이전글이 없습니다.");
            });
        } else {
            prev.href = `/customer/notice/${prevId}`;
        }

        const next = document.getElementById('next');

        if (nextId === parseInt(noticeId)) {
            next.addEventListener("click", function() {
                alert("다음글이 없습니다.");
            });
        } else {
            next.href = `/customer/notice/${nextId}`;
        }

    })
    .catch(error => {
        console.error("Error:", error);
    });
}

3 수정하기

3.1 view

수정하기는 값을 먼저 불러온 후 input박스에 담아두고 그값을 보내주어야한다.
update버튼을 누르면 삭제할 파일들을의 이름을 배열에 담아준다.
그리고 patch요청을 하고 싶었으나 수정시에 파일들도 보내야하기 때문에 어쩔수 없이 POST요청을 하게 되었다.
POST요청을 하기 위해 form태그를 사용해서 쉽게 보낼 수 있지만
formData타입에 담아서 비동기 요청을 했다.

const updateBtn = document.getElementById("update-btn");
updateBtn.addEventListener("click", function () {
    const url = window.location.href;
    const noticeId = url.substring(url.lastIndexOf("/") + 1);

    const checkboxes = document.getElementsByName("removeFiles");

    const removeFiles = [];

    for (let i = 0; i < checkboxes.length; i++) {
        if (checkboxes[i].checked) {
            removeFiles.push(checkboxes[i].value);
        }
    }

    const formData = new FormData();

    const fileInput = document.getElementById("formFile");
    const files = fileInput.files;

    for (let i = 0; i < files.length; i++) {
        formData.append("files", files[i]);
    }

    formData.append("removeFiles", removeFiles);
    formData.append("noticeId", noticeId);
    formData.append("title", document.getElementById("title").value);
    formData.append("content", document.getElementById("content").value);

    fetch(`/api/notices/${noticeId}`, {
        method: "POST",
        headers: {},
        body: formData
    })
        .then(response => {
            if (response.ok) {
                location.href = "/customer/notice/list"
            } else {
            }
        })
        .catch(error => {
            console.error("Error:", error);
        });
});

const formData = new FormData();
formData객체를 만들고 자바 list에 값을 담듯이 append하면 된다.
응답을 보내서 성공하면 list페이지로 리다이렉트 한다.

3.2 컨트롤러

지울 파일들은 removeFileNames로 string인 List로
파일들은 MultipartFile타입으로
Notice는 notice domain에 담아서 알아서 매핑이 된다.
수정완료되었음을 응답으로 돌려준다.

@PostMapping("{noticeId}")
  public ResponseEntity<Map<String, Object>> modify(
          @PathVariable("noticeId") Integer noticeId,
          Notice notice,
          @RequestParam(value = "removeFiles", required = false) List<String> removeFileNames,
          @RequestParam(value = "files", required = false) MultipartFile[] files) {
      try {
          boolean ok = noticeService.modify(notice, files, removeFileNames);
      } catch (IOException e) {
          throw new RuntimeException(e);
      }
      return ResponseEntity.ok()
              .body(Collections.singletonMap("message", "Notice modified successfully."));
  }

3.3 서비스

수정은 파일 삭제와 등록을 모두 해주어야한다.
받아온 파일명으로 aws s3에서 파일을 삭제해주고 파일 테이블에서 파일 명을 삭제해준다.
그리고 update 쿼리로 상품 정보를 수정해주고
aws s3에 파일을 등록하고 파일테이블에 파일명을 등록해주었다.

   @Override
    public List<Notice> getNoticeList(String search, String type) {
        List<Notice> noticeList = noticeMapper.getNoticeList(search, type);
        return noticeList;
    @Transactional(rollbackFor = Exception.class)
    public boolean modify(Notice notice, MultipartFile[] files, List<String> removeFileNames) throws IOException {
        notice.setModifierId("chun2");
        if (removeFileNames != null && !removeFileNames.isEmpty()) {
            for (String fileName : removeFileNames) {
                // 파일 삭제
                String objectKey = "career_fair/notice/" + notice.getNoticeId() + "/" + fileName;
                DeleteObjectRequest dor = DeleteObjectRequest.builder()
                        .bucket(bucketName)
                        .key(objectKey)
                        .build();

                s3.deleteObject(dor);

                // FileName 테이블의 데이터 삭제
                noticeMapper.deleteFileNameByNoticeIdAndFileName(notice.getNoticeId(), fileName);
            }
        }

        // 상품 정보 수정
        int cnt = noticeMapper.update(notice);

        if (files != null) {
            // 파일등록
            for (MultipartFile file : files) {
                if (file.getSize() > 0) {
                    String objectKey = "career_fair/notice/" + notice.getNoticeId() + "/" + file.getOriginalFilename();

                    // s3에 파일 업로드
                    PutObjectRequest por = PutObjectRequest.builder()
                            .bucket(bucketName)
                            .acl(ObjectCannedACL.PUBLIC_READ)
                            .key(objectKey)
                            .build();
                    RequestBody rb = RequestBody.fromInputStream(file.getInputStream(),
                            file.getSize());

                    s3.putObject(por, rb);

                    // db에 관련정보저장 (insert)
                    noticeMapper.insertFileName(notice.getNoticeId(), file.getOriginalFilename());
                }
            }
        }
        return cnt == 1;
    }

3.4 매퍼

특이점은 없다.

<!-- 상품 수정 -->
<update id="update">
    UPDATE TB_NOTICE SET
    title = #{title},
    content = #{content},
    modifier_id = #{modifierId},
    modified = NOW()
    WHERE
    notice_id =
    #{noticeId}
</update>

<!-- 파일관련 -->
<insert id="insertFileName">
    INSERT INTO TB_FILES (notice_id, file_name)
    VALUES (#{noticeId}, #{originalFilename})
</insert>

<!-- 특정상품의 특정사진 삭제 -->
<delete id="deleteFileNameByNoticeIdAndFileName">
    DELETE FROM TB_FILES
    WHERE notice_id = #{noticeId}
    AND file_name = #{fileName}
</delete>

2023.07.06

사용하지 않았던 restapi의 형식을 따르려고 하니까 좀 시간이 걸리는 것같다.

+ Recent posts