v10
★ MILESTONE · REST

자잘한 비동기 + 첨부파일

v9 패턴의 응용

학습 목표

  • 기존 테이블에 컬럼을 더하는 ALTER 두 번을 직접 한다
  • 조회수 즉시 증가 — fetch 패턴 응용
  • 첨부파일 — MultipartFile + NULL 허용 컬럼
  • v8/v9 패턴이 자연스럽게 반복되는 경험

⚠️ v9 까지의 한계

아직 부족한 게 있다
  • 「인기글」 표시하려는데 — 조회수 가 어디에도 없음
  • 「이미지 첨부 요청」 — 글 본문에 사진을 못 붙임
  • v5~v9 까지 만든 myboard 는 두 컬럼이 비어 있음

두 가지를 「기존 테이블에 ALTER 로 추가」 하면서 v9 의 fetch 패턴을 그대로 응용합니다.

① 첫 ALTER — 조회수 컬럼 추가


ALTER TABLE myboard
    ADD COLUMN view_count INT DEFAULT 0;

👉 DEFAULT 0 덕분에 기존 글들도 자동으로 0 부터 시작. 새로 만들 필요 없음.


// com.smhrd.domain.Board — 필드 한 줄 추가
private int viewCount;

👉 카멜 케이스 매핑: view_countviewCount (MyBatis mapUnderscoreToCamelCase).

① 조회수 — Controller


@PostMapping("/api/boards/{num}/view")
public Map<String, Integer> incrementView(@PathVariable int num) {
    int newCount = service.incrementViewCount(num);
    return Map.of("viewCount", newCount);
}

// 글 상세 페이지 진입 시
const BOARD_NUM = ${board.num};
fetch(`/api/boards/${BOARD_NUM}/view`, {method: 'POST'})
    .then(r => r.json())
    .then(d => {
        document.querySelector('#viewCount').textContent = d.viewCount;
    });

👉 페이지 들어가자마자 +1, 화면도 즉시 갱신.

Service + Mapper — 조회수 +1


@Service
public class BoardService {
    @Autowired private BoardMapper mapper;

    @Transactional
    public int incrementViewCount(int num) {
        mapper.incrementViewCount(num);
        return mapper.selectOne(num).getViewCount();
    }
}

<update id="incrementViewCount" parameterType="int">
    UPDATE myboard SET view_count = view_count + 1 WHERE num = #{num}
</update>

② 두 번째 ALTER — 첨부 컬럼 추가


ALTER TABLE myboard
    ADD COLUMN photo VARCHAR(200) NULL;

👉 NULL 허용이 핵심. 기존 글 전부 사진이 없으니 NULL 로 두고, 새로 올린 글만 파일명을 채움.


// com.smhrd.domain.Board — 한 줄 더 추가
private String photo;     // NULL 가능 — 사진 없는 글

② 동적 SQL — photo 가 있을 때만 UPDATE


<update id="update" parameterType="com.smhrd.domain.Board">
    UPDATE myboard
    SET title = #{title}, content = #{content}
    <if test="photo != null">
        , photo = #{photo}
    </if>
    WHERE num = #{num}
</update>

👉 글만 수정하면 사진은 그대로, 새 사진을 첨부했을 때만 photo 컬럼도 함께 변경. MyBatis 동적 SQL 의 첫 등장.

③ 파일 업로드 — pom.xml


<dependency>
    <groupId>commons-fileupload</groupId>
    <artifactId>commons-fileupload</artifactId>
    <version>1.5</version>
</dependency>

③ MultipartResolver 등록


<!-- root-context.xml -->
<bean id="multipartResolver"
      class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
    <property name="maxUploadSize" value="10485760" /> <!-- 10 MB -->
    <property name="defaultEncoding" value="UTF-8" />
</bean>

👉 maxUploadSize 로 파일 크기 제한.

③ Controller — 파일 받기


@PostMapping("/api/boards/upload")
public Map<String, String> upload(@RequestParam("file") MultipartFile file)
        throws IOException {

    if (file.isEmpty()) {
        throw new IllegalArgumentException("파일 없음");
    }

    String original = file.getOriginalFilename();
    String saved = UUID.randomUUID() + "_" + original;
    File dest = new File("/uploads/" + saved);
    file.transferTo(dest);

    // 응답 JSON 의 filename 을 Board.photo 에 넣어 저장하면 됨
    return Map.of(
        "filename", saved,
        "originalName", original,
        "size", String.valueOf(file.getSize())
    );
}

③ 클라이언트 — FormData


<input type="file" id="fileInput" />
<button onclick="upload()">업로드</button>
<div id="result"></div>

async function upload() {
    const input = document.getElementById('fileInput');
    if (!input.files[0]) return;

    const formData = new FormData();
    formData.append('file', input.files[0]);

    const res = await fetch('/api/boards/upload', {
        method: 'POST',
        body: formData      // ⭐ Content-Type 자동 설정
    });

    const data = await res.json();
    document.getElementById('result').innerHTML =
        `업로드 완료: ${data.originalName}`;
}

FormData — multipart 자동 처리

FormData 객체에 파일을 추가하면:

  • 요청의 Content-Type 이 자동으로 multipart/form-data; boundary=...
  • 파일 데이터가 자동 인코딩
  • 여러 파일·필드 한 번에 가능

formData.append('file', input.files[0]);
formData.append('description', '설명 텍스트');
formData.append('boardnum', 3);

업로드 진행률 — 진행률 측정


function uploadWithProgress(file) {
    const xhr = new XMLHttpRequest();
    const formData = new FormData();
    formData.append('file', file);

    xhr.upload.onprogress = (e) => {
        if (e.lengthComputable) {
            const percent = (e.loaded / e.total) * 100;
            document.getElementById('progress').textContent =
                `${percent.toFixed(1)}%`;
        }
    };
    /* 다음 슬라이드 — 완료 처리 */
}

👉 upload.onprogress 가 보낸 바이트 / 전체 바이트로 진행률을 알려줍니다.

업로드 진행률 — 완료 처리·전송


    xhr.onload = () => {
        if (xhr.status === 200) {
            const data = JSON.parse(xhr.responseText);
            // 완료 처리
        }
    };

    xhr.open('POST', '/api/boards/upload');
    xhr.send(formData);
}

👉 fetch 는 진행률 안 됨. 큰 파일은 XMLHttpRequest 또는 라이브러리 (axios 등).

파일 업로드 보안 주의

⚠️ 사용자 업로드는 위험
  • 확장자 검증 — .jpg, .png 만 허용 등
  • 크기 제한 — maxUploadSize 설정
  • 파일명 변경 — UUID 등으로 (사용자 입력 그대로 X)
  • 저장 경로 — 웹 루트 밖에 저장 (/uploads 등)
  • 실행 권한 X — 업로드된 파일이 실행되지 않게

v10 의 의미

v10 은 새 패턴 학습이 아니라 — 기존 테이블에 컬럼을 더하는 ALTER + v9 의 fetch 패턴 반복.

  • 「인기글이 필요해서」 → ALTER ADD view_count INT DEFAULT 0
  • 「이미지 첨부가 필요해서」 → ALTER ADD photo VARCHAR(200) NULL
  • NULL 허용 컬럼은 동적 SQL <if test="photo != null"> 와 짝

실전 — 우리 게시판 완성도

기능v6 (JSP)v10 (REST + 비동기)
목록·상세
작성·수정·삭제
본인 확인
페이징v7v7
댓글 (비동기)v9
조회수v10 ⭐ (ALTER + view_count)
사진 첨부v10 ⭐ (ALTER + photo)

👉 우리 게시판이 실용적인 수준에 도달.

🔄 Before / After

v9 끝

myboard 4 컬럼 + created_at. 조회수·사진 없음.

v10 끝

myboard 에 view_count·photo 두 컬럼이 ALTER 로 추가. 동적 SQL 첫 등장.

정리

오늘 들고 가는 것

  • ALTER ADD COLUMN — 기존 테이블에 컬럼 더하기 (DEFAULT / NULL 두 가지 패턴)
  • incrementViewCount UPDATE — 조회수 +1 의 표준 SQL
  • 동적 SQL <if test="photo != null"> — NULL 허용 컬럼의 짝
  • MultipartFile + FormData 로 파일 업로드
  • 다음 — ★ v∞ 디버깅 워크숍 (4 주 학습의 마무리)