[LG CNS AM CAMP 1기] 백엔드 II 4 | SpringBoot

letthem·2025년 1월 23일
0

LG CNS AM CAMP 1기

목록 보기
20/31
post-thumbnail

새 글 등록 기능

boardList.html 파일에 글쓰기 버튼을 추가

boardList.html

<a href="/board/openBoardWrite.do" class="btn">글쓰기</a>

등록 화면 추가

**boardWrite.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<meta charset="UTF-8">
<title>게시판</title>
<link rel="stylesheet" th:href="@{/css/board.css}" />
</html>
<body>
<div class="container">
    <h2>게시판 등록</h2>
    <form id="form" method="post" action="/board/insertBoard.do">
        <table class="board_detail">
            <tr>
                <td>제목</td>
                <td><input type="text" id="title" name="title" /></td>
            </tr>
            <tr>
                <td colspan="2"><textarea id="contents" name="contents"></textarea></td>
            </tr>
        </table>
        <input type="submit" id="submit" value="저장" class="btn" />
    </form>
</div>
</body>

등록 화면 추가

BoardController

// 글쓰기 화면 요청을 처리하는 메서드
@GetMapping("/board/openBoardWrite.do")
public String openBoardWrite() throws Exception {
    return "/board/boardWrite"; // 라우팅
}

// 글 저장 요청을 처리하는 메서드
@PostMapping("/board/insertBoard.do")
public String insertBoard(BoardDto boardDto) throws Exception {
    boardService.insertBoard(boardDto);
    return "redirect:/board/openBoardList.do"; // 리다이렉션 (요청을 다른 요청으로 바꾸는 것)
}

서비스 인터페이스와 구현 클래스에 메서드를 추가

BoardService 인터페이스

public interface BoardService {
    List<BoardDto> selectBoardList();

    void insertBoard(BoardDto boardDto);
}

BoardServiceImpl 구현 클래스

@Override
public void insertBoard(BoardDto boardDto) {
    // 로그인한 사용자를 글쓴이로 설정
    // TODO. 로그인한 사용자의 ID로 변경
    boardDto.setCreatedId("hong");
    
    boardMapper.insertBoard(boardDto);

매퍼 인터페이스에 메서드를 추가하고, 쿼리를 등록

BoardMapper 인터페이스

@Mapper
public interface BoardMapper {
    List<BoardDto> selectBoardList();

    void insertBoard(BoardDto boardDto);
}

sql-board.xml

<!--
    외부 입력값을 쿼리에 반영할 경우, #{ }을 이용해야 SQL 인젝션 공격을 방어할 수 있음
-->
<insert id="insertBoard" parameterType="board.dto.BoardDto">
    insert into t_board(title, contents, created_dt, created_id)
    values (#{title}, #{contents}, now(), #{createdId})
</insert>

리다이렉션

저장 누르면

302 코드 - 클라이언트 안 거치고 서버에서 redirect된다.
cf


게시물 상세 조회 기능

목록 화면에서 게시물 제목을 클릭하면 상세 화면으로 이동하고, 상세 내용을 조회하면 조회수를 증가

목록 조회 화면을 수정

boardList.html

<!--
<td th:text="${list.title}" class="title">게시판 글 제목</td>
-->
<td class="title">
    <a href="/board/openBoardDetail.do?boardIdx="
       th:attrappend="href=${list.boardIdx}" th:text="${list.title}"></a>
</td>

BoardController에 상세 조회 요청을 처리할 메서드를 추가

BoardController

// 상세 조회 요청을 처리하는 메서드
// /board/openBoardDetail.do?boardIdx=1234
@GetMapping("/board/openBoardDetail.do")
public ModelAndView openBoardDetail(@RequestParam("boardInx") int boardIdx) throws Exception {
    BoardDto boardDto = boardService.selectBoardDetail(boardIdx);
    
    ModelAndView mv = new ModelAndView("/board/boardDetail");
    mv.addObject("board", boardDto);
    return mv;
}

쿼리스트링 뽑아내려면 @RequestParam("") 사용한다

BoardService

public interface BoardService {
    List<BoardDto> selectBoardList();

    void insertBoard(BoardDto boardDto);

    BoardDto selectBoardDetail(int boardIdx);
}

BoardDto selectBoardDetail(int boardIdx); 추가

BoardServiceImpl

@Override
public BoardDto selectBoardDetail(int boardIdx) {
    return boardMapper.selectBoardDetail(boardIdx);
}

BoardMapper

@Mapper
public interface BoardMapper {
    List<BoardDto> selectBoardList();

    void insertBoard(BoardDto boardDto);

    BoardDto selectBoardDetail(int boardIdx);
}

sql-board.xml

<select id="selectBoardDetail" parameterType="int" resultType="board.dto.BoardDto">
    select board_idx, title, hit_cnt, date_format(created_dt, '%Y.%m.%d %H:%i:%s') as created_dt,
           contents, created_id
    from t_board
    where deleted_yn = 'N' and board_idx = #{boardIdx}
</select>

뷰 작성

boardDetail.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<meta charset="UTF-8">
<title>게시판</title>
<link rel="stylesheet" th:href="@{/css/board.css}" />
</html>
<body>
<div class="container">
    <h2>게시판 상세</h2>
    <form id="form" method="post" action="/board/insertBoard.do">
        <table class="board_detail">
            <colgroup>
                <col width="15%" />
                <col width="*" />
                <col width="15%" />
                <col width="35%" />
            </colgroup>
            <tr>
                <th>글번호</th>
                <td th:text="${board.boardIdx}"></td>
                <th>조회수</th>
                <td th:text="${board.hitCnt"></td>
            </tr>
            <tr>
                <th>작성자</th>
                <td th:text="${board.createdId}"></td>
                <th>작성일</th>
                <td th:text="${board.createdDt}"></td>
            </tr>
            <tr>
                <th>제목</th>
                <td colspan="3"><input type="text" id="title" name="title" th:value="${board.title}"/></td>
            </tr>
            <tr>
                <td colspan="4"><textarea id="contents" name="contents" th:text="${board.contents}"</td>
            </tr>
        </table>
        <input type="submit" id="submit" value="저장" class="btn" />
    </form>
</div>
</body>

게시글 수정, 삭제 기능

상세화면(boardDetail.html)에 기능 버튼을 추가

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<meta charset="UTF-8">
<title>게시판</title>
<link rel="stylesheet" th:href="@{/css/board.css}" />
</html>
<body>
<div class="container">
    <h2>게시판 상세</h2>
    <form id="frm" method="post" action="/board/insertBoard.do">
        <table class="board_detail">
            <colgroup>
                <col width="15%" />
                <col width="*" />
                <col width="15%" />
                <col width="35%" />
            </colgroup>
            <tr>
                <th>글번호</th>
                <td th:text="${board.boardIdx}"></td>
                <th>조회수</th>
                <td th:text="${board.hitCnt}"></td>
            </tr>
            <tr>
                <th>작성자</th>
                <td th:text="${board.createdId}"></td>
                <th>작성일</th>
                <td th:text="${board.createdDt}"></td>
            </tr>
            <tr>
                <th>제목</th>
                <td colspan="3"><input type="text" id="title" name="title" th:value="${board.title}"/></td>
            </tr>
            <tr>
                <td colspan="4"><textarea id="contents" name="contents" th:text="${board.contents}"</td>
            </tr>
        </table>
    </form>

    <input type="button" id="list" class="btn" value="목록으로" />
    <input type="button" id="update" class="btn" value="수정하기" />
    <input type="button" id="delete" class="btn" value="삭제하기" />

    <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
    <script>
        $(function() {
            $("#list").on("click", function() {
                location.href = "openBoardList.do";
            });
            $("#update").on("click", function() {
                let frm = $("#frm")[0];
                frm.action = "updateBoard.do";
                frm.submit();
            });
            $("#delete").on("click", function() {
                let frm = $("#frm")[0];
                frm.action = "deleteBoard.do";
                frm.submit();
            });
        });
    </script>
</div>
</body>

BoardController에 수정 요청과 삭제 요청을 처리할 메서드를 추가

// 수정 요청을 처리할 메서드
@PostMapping("/board/updateBoard.do")
public String updateBoard(BoardDto boardDto) throws Exception {
    boardService.updateBoard(boardDto);
    return "redirect:/board/openBoardList.do";
}

// 삭제 요청을 처리할 메서드
@PostMapping("/board/deleteBoard.do")
public String deleteBoard(@RequestParam("boardIdx") int boardIdx) throws Exception {
    boardService.deleteBoard(boardIdx);
    return "redirect:/board/openBoardList.do";
}

BoardService

void updateBoard(BoardDto boardDto);
void deleteBoard(int boardIdx);

BoardServiceImpl

@Override
public void updateBoard(BoardDto boardDto) {
    // TODO. 로구인한 사용자 아이디로 변경
    boardDto.setUpdatorId("go");
    boardMapper.updateBoard(boardDto);
}

@Override
public void deleteBoard(int boardIdx) {
    BoardDto boardDto = new BoardDto();
    // TODO. 로그인한 사용자 아이디로 변경
    boardDto.setUpdatorId("go");
    boardMapper.deleteBoard(boardDto);
}

BoardMapper

void updateBoard(BoardDto boardDto);
void deleteBoard(BoardDto boardDto);

sql-board.xml

<update id="updateBoard" parameterType="board.dto.BoardDto">
    update t_board
      set title = #{title}
        , contents = #{contents}
        , updator_dt = now()
        , updator_id = #{updator_id}
    where board_idx = #{boardIdx}
</update>

<delete id="deleteBoard" parameterType="board.dto.BoardDto">
    update t_board
      set delete_yn = 'Y'
        , contents = #{contents}
        , updator_dt = now()
        , updator_id = #{updator_id}
    where board_idx = #{boardIdx}
</delete>

조회수 증가 기능

BoardServiceImpl에 상세 조회 시 조회수 증가 기능을 추가

@Override
public BoardDto selectBoardDetail(int boardIdx) {
    boardMapper.updateHitCnt(boardIdx);
    return boardMapper.selectBoardDetail(boardIdx);
}

BoardMapper

void updateHitCnt(int boardIdx);

sql-board.xml

<update id="updateHitCnt" parameterType="int">
    update t_board
        set hit_cnt = hit_cnt + 1
    where board_idx = #{boardIdx}
</update>

여기까지 CRUD 를 Spring MVC 패턴으로 만들어보았다 !!!!!! 이제 부가 기능을 만들자


Loging

Logback

설정 파일(application.properties)

logging.level.root=OFF
logging.level.board=DEBUG
logging.pattern.console=%d{HH:mm:ss.SSS} %highlight(%-5p) %cyan(%c) %m%n

BoardApplication (main)

package board;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BoardApplication {

	private static Logger log = LoggerFactory.getLogger(BoardApplication.class);

	public static void main(String[] args) {
		log.trace("trace");
		log.debug("debug");
		log.info("info");
		log.warn("warn");
		log.error("error");
		SpringApplication.run(BoardApplication.class, args);
	}

}

@Slf4j
@SpringBootApplication
public class BoardApplication {

	//private static Logger log = LoggerFactory.getLogger(BoardApplication.class);

	public static void main(String[] args) {
		log.trace("trace");
		log.debug("debug");
		log.info("info");
		log.warn("warn");
		log.error("error");
		SpringApplication.run(BoardApplication.class, args);
	}

}

private static Logger log = LoggerFactory.getLogger(BoardApplication.class); 대신

@Slf4j를 사용할 수 있다.

@SpringBootApplication
public class BoardApplication {

	public static void main(String[] args) {
		SpringApplication.run(BoardApplication.class, args);
	}
}

이렇게 쓰면 된다!

Log4JDBC를 이용해서 쿼리 로그를 정렬

현재 로그 설정 상태에서도 쿼리문의 구조, 적용할 값, 결과가 로그로 출력되는 것을 확인
=> but, 읽기 어렵고 값이 대입된 상태의 쿼리를 확인할 수 없음

Log4JDBC

JDBC 드라이버의 SQL 활동을 콘솔 또는 파일에 출력하는 용도로 사용
SQL 쿼리와 데이터베이스 상호 작용을 투명하게 추적하는 것이 가능하다 !
SQL 쿼리 실행 시간을 측정하고, 실행된 쿼리를 기록하며, 디버깅 및 성능 튜닝을 용이하게 함

build.gradle

implementation group: 'org.bgee.log4jdbc-log4j2', name: 'log4jdbc-log4j2-jdbc4.1', version: '1.16'

log4jdbc 설정 파일을 생성

resources/log4jdbc.log4j2.properties

log4jdbc.spylogdelegator.name=net.sf.log4jdbc.log.slf4j.Slf4jSpyLogDelegator
log4jdbc.dump.sql.maxlinelength=0

application.properties 변경

#spring.datasource.hikari.driver-class-name=com.mysql.cj.jdbc.Driver
#spring.datasource.hikari.jdbc-url=jdbc:mysql://localhost:3306/springbootdb?useUnicode=true&characterEncoding=utf-8&serverTimeZone=Asia/Seoul

spring.datasource.hikari.driver-class-name=net.sf.log4jdbc.sql.jdbcapi.DriverSpy
spring.datasource.hikari.jdbc-url=jdbc:log4jdbc:mysql://localhost:3306/springbootdb?useUnicode=true&characterEncoding=utf-8&serverTimeZone=Asia/Seoul

logging.level.jdbc.sqlonly=info
logging.level.jdbc.resultsettable=info

이렇게 예쁘게 로그가 나온다 !

log4jdbc 로거

jdbc.sqlonly SQL을 출력
Prepared Statement의 경우, 관련된 파라미터는 자동으로 변경되어 출력
jdbc.sqltiming SQL 문과 해당 SQL 문의 실행 시간을 밀리초 단위로 출력
jdbc.audit ResultSet을 제외한 모든 JDBC 호출 정보를 출력 (잘 안 쓴다.)
jdbc.resultset ResultSet을 포함한 모든 JDBC 호출 정보를 출력 (잘 안 쓴다.)
jdbc.resultsettable SQL 문의 조회 결과를 테이블 형식으로 출력 (잘 안 쓴다.)
jdbc.connection Connection의 연결과 종료에 관련된 로그를 출력

Prepared Statement

int id = 100;
Statement statement = Connection.createStatement();
String sql = "select * from t_board where id = " + id;	⇐ 쿼리 생성을 개발자가 담당
statement.executeQuery(sql); 


int id = 100;
String sql = "select * from t_board where id = ?";
PreparedStatement statement = Connection.prepareStatement(sql);
statement.setInt(1, id);					⇐ 쿼리 생성을 PreparedStatement가 담당
statement.executeQuery();

인터셉터(Interceptor)

스프링 프레임워크에서 특정 요청을 가로채고 처리하기 위해 사용되는 도구
주로 요청 전후에 공통된 작업을 실행하거나, 요청을 처리하는 컨트롤러로 가기 전에 요청을 변경 또는 검사할 때 사용

스프링 MVC에서 인터셉터는 HandlerInterceptor 인터페이스를 상속받아서 구현

  • preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)

    • 컨트롤러가 호출되기 전에 실행
    • true를 반환하면 다음 인터셉터 또는 컨트롤러가 호출되고, false를 반환하면 요청 처리를 중단
  • postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView)

    • 컨트롤러 실행 후 결과를 뷰로 보내기 전에 수행
    • ModelAndView 객체를 통해 뷰에 전달할 데이터 조작이 가능
  • afterCompletion()

    • 뷰 렌더링 후 호출
    • 주로 리소스를 정리하거나 로깅 등의 작업에 사용

요청의 시작과 끝에 로그를 출력하는 인터셉터를 작성

board/interceptor/LoggerInterceptor

package board.interceptor;

import org.springframework.lang.Nullable;
import org.springframework.web.servlet.HandlerInterceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class LoggerInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        log.debug("************** START **************");
        log.debug(" Request URI \t" + request.getRequestURI());
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
                                @Nullable Exception ex) throws Exception {
        log.debug("**************  END  **************");
    }
}

LoggerInterceptor 등록

스프링 4.0 이상에서는 자바 기반 설정을 지원

WebMvcConfigurer 인터페이스를 상속받는 설정 클래스를 추가

configuration/WebMvcConfiguration

package board.configuration;

import board.interceptor.LoggerInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoggerInterceptor());
    }
}

AOP

컨트롤러, 서비스, 매퍼의 메서드가 호출될 때 각 메서드의 경로와 이름을 로그로 출력하는 공통 모듈을 구현

build.gradle

implementation 'org.springframework:spring-aspects:6.2.2'

aop.LoggerAspect

package board.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

// SpringBoot에서는 @EnableAspectJAutoProxy 어노테이션을 추가하지 않아도 자동으로 AOP 설정을 활성화
@Aspect
@Slf4j
@Component
public class LoggerAspect {
    @Pointcut("execution(* board..controller.*Controller.*(..)) || execution(* board..service.*ServiceImpl.*(..)) || execution(* board..mapper.*Mapper.*(..))")
    private void loggerTarget() {

    }

    @Around("loggerTarget()")
    public Object logPrinter(ProceedingJoinPoint joinPoint) throws Throwable {
        String type = "";
        String className = joinPoint.getSignature().getDeclaringTypeName();
        String methodName = joinPoint.getSignature().getName();

        if (className.indexOf("Controller") > -1) {
            type = "[Controller]";
        } else if (className.indexOf("Service") > -1) {
            type = "[Service]";
        } else if (className.indexOf("Mapper") > -1) {
            type = "[Mapper]";
        }
        log.debug(type + " " + className + "." + methodName);
        return joinPoint.proceed();
    }
}


트랜잭션

트랜잭션을 적용하지 않는 경우

게시판 상세 조회 -> 조회수를 증가시키고, 게시판 정보를 조회
                ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                두 가지가 함께 처리되어야 함 => 트랜잭션 적용 대상
@Override
    public BoardDto selectBoardDetail(int boardIdx) {
        boardMapper.updateHitCnt(boardIdx);
        int i = 10 / 0;				⇐ 오류를 발생시키는 코드를 추가 
        return boardMapper.selectBoardDetail(boardIdx);
    }
게시판 목록 조회 -> 조회수를 확인 -> 게시판 상세 조회 -> 오류 발생 -> 게시판 목록 조회 -> 조회수를 확인 
                                                        ~~~~~~~~~~~~~~~~~~~~~~~~~~
                                                        조회수 증가를 확인
                                                        => 트랜잭션 처리가 되고 있지 않음

@Transactoinal

선언적 트랜잭션 관리 => @Transactional 어노테이션을 이용해서 트랜잭션을 적용

어노테이션만 사용하면 되기 때문에 쉽게 적용할 수 있음
원하는 클래스 또는 메서드 단위로 트랜잭션을 설정하는 것이 가능
새로운 메서드 또는 클래스를 만들 때마다 어노테이션을 적용해야 함
외부 라이브러리를 사용하는 경우, 해당 라이브러리 코드를 편집할 수 없는 제한 사항이 발생 => AOP를 이용해서 트랜잭션을 구현
트랜잭션 => 한꺼번에 처리한다

트랜잭션을 적용할 클래스에 @Transactional 어노테이션을 추가 ⇒ BoardServiceImpl

@Transactional
@Service
public class BoardServiceImpl implements BoardService {
	... (생략) ...

결과 확인

게시판 목록 조회 → 조회수 확인 → 게시판 상세 조회 → 오류 발생 → 게시판 목록 조회 → 조회수 확인
                                                                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                                                                  조회수가 증가하지 않는 것을 확인
                                                                  => rollback 
                                                                  => 트랜잭션 처리가 되고 있음

BoardServiceImpl 클래스에 적용한 @Transactional 어노테이션을 메서드에 적용하는 것으로 변경

// @Transactional
@Service
public class BoardServiceImpl implements BoardService {

    @Transactional
    @Override
    public BoardDto selectBoardDetail(int boardIdx) {
        boardMapper.updateHitCnt(boardIdx);
        int i = 10 / 0; // <= 오류가 나는 코드
        return boardMapper.selectBoardDetail(boardIdx);
    }

	... (생략) ...

결과 확인

뒤로가기를 해도 조회수가 동일하다. => 트랜잭션이 잘 적용되는구나 !


AOP를 이용해서 트랜잭션을 설정

aop/TransactionAspect

package board.aop;

import org.springframework.aop.Advisor;
import org.springframework.aop.aspectj.AspectJExpressionPointcut;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.interceptor.*;

import java.util.Arrays;


@Configuration
public class TransactionAspect {

    // 트랜잭션 관리자
    @Autowired
    private PlatformTransactionManager transationManager;

    // 트랜잭션 인터셉터 정의
    // 트랜잭션 관리자를 사용해서 트랜잭션 시작, 커밋, 롤백 등의 처리를 수행
    @Bean
    TransactionInterceptor transactionAdvice() {
        TransactionInterceptor transactionInterceptor = new TransactionInterceptor();
        transactionInterceptor.setTransactionManager(transationManager);

        // 모든 메서드에 동일한 트랜잭션 속성을 적용
        MatchAlwaysTransactionAttributeSource source = new MatchAlwaysTransactionAttributeSource();

        // 트랜잭션 속성을 정의 -> 트랜잭션 이름, 롤백 규칙
        RuleBasedTransactionAttribute transactionAttribute = new RuleBasedTransactionAttribute();
        transactionAttribute.setName("*");
        transactionAttribute.setRollbackRules(Arrays.asList(new RollbackRuleAttribute(Exception.class)));
        source.setTransactionAttribute(transactionAttribute);

        transactionInterceptor.setTransactionAttributeSource(source);

        return transactionInterceptor;
    }

    // AOP 포인트컷과 어드바이저 설정
    @Bean
    Advisor transactionAdviceAdvisor() {
        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression("execution(* board..service.*Impl.*(..))");

        return new DefaultPointcutAdvisor(pointcut, transactionAdvice());
    }
}

BoardServiceImpl 클래스에서 @Transactional 어노테이션을 제거 후 테스트를 진행

// @Transactional
@Service
public class BoardServiceImpl implements BoardService {

    @Transactional
    @Override
    public BoardDto selectBoardDetail(int boardIdx) {
        boardMapper.updateHitCnt(boardIdx);
        int i = 10 / 0;
        return boardMapper.selectBoardDetail(boardIdx);
    }

	... (생략) ...

파일 업로드와 다운로드

파일 정보를 저장할 테이블을 생성 (mysql)

create table t_file (
	idx int(10) unsigned not null auto_increment comment '일련번호', 
    board_idx int(10) unsigned not null comment '게시글 번호', 
    original_file_name varchar(255) not null comment '원본 파일 이름', 
    stored_file_path varchar(500) not null comment '파일 저장 경로',
    file_size int(15) unsigned not null comment '파일 크기', 
    created_id varchar(50) not null comment '작성자 아이디', 
    created_dt datetime not null comment '작성 일시', 
    updator_id varchar(50) null comment '수정자 아이디', 
    updator_dt datetime null comment '수정 시간', 
    deleted_yn char(1) not null default 'N' comment '삭제 여부', 
    primary key (idx));

업로드 관련 설정을 추가 ⇒ application.properties

spring.servlet.multipart.enabled=true
spring.servlet.multipart.location=/Users/letthem/uploads
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=30MB

업로드 파일을 저장할 디렉터리를 생성

터미널에서 mkdir -p ~/uploads

글쓰기 페이지(boardWrite.html)에 파일을 첨부할 수 있도록 수정

파일을 포함하면 무조건 multipart/form-data 이걸 써야한다.

enctype="multipart/form-data"
<input type="file" id="files" name="files" multiple="multiple" />

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<meta charset="UTF-8">
<title>게시판</title>
<link rel="stylesheet" th:href="@{/css/board.css}" />
</html>
<body>
<div class="container">
    <h2>게시판 등록</h2>
    <form id="frm" method="post" action="/board/insertBoard.do" enctype="multipart/form-data">
        <table class="board_detail">
            <tr>
                <td>제목</td>
                <td><input type="text" id="title" name="title" /></td>
            </tr>
            <tr>
                <td colspan="2"><textarea id="contents" name="contents"></textarea></td>
            </tr>
        </table>
        <input type="file" id="files" name="files" multiple="multiple" />
        <input type="submit" id="submit" value="저장" class="btn" />
    </form>
</div>
</body>

컨트롤러를 변경

// 글 저장 요청을 처리하는 메서드
@PostMapping("/board/insertBoard.do")
public String insertBoard(BoardDto boardDto, MultipartHttpServletRequest request) throws Exception {
    boardService.insertBoard(boardDto, request);
    return "redirect:/board/openBoardList.do"; // 리다이렉션 (요청을 다른 요청으로 바꾸는 것)
}

BoardService

void insertBoard(BoardDto boardDto, MultipartHttpServletRequest request);

BoardServiceImpl

// @Transactional
@Slf4j
@Service
public class BoardServiceImpl implements BoardService {
    @Autowired
    private BoardMapper boardMapper;
    
    @Override
    public List<BoardDto> selectBoardList() {
        return boardMapper.selectBoardList();
    }

    @Override
    public void insertBoard(BoardDto boardDto, MultipartHttpServletRequest request) {
        // 로그인한 사용자를 글쓴이로 설정
        // TODO. 로그인한 사용자의 ID로 변경
        // boardDto.setCreatedId("hong");
        // boardMapper.insertBoard(boardDto);
        
        if (!ObjectUtils.isEmpty(request)) {
            // <input type="file" name="이 속성의 값" />
            Iterator<String> fileTagNames = request.getFileNames();
            while(fileTagNames.hasNext()) {
                String fileTagName = fileTagNames.next();
                // 하나의 <input type="file" multiple="multiple"> 태그를 통해서 전달된 파일들을 가져옮
                List<MultipartFile> files = request.getFiles(fileTagName);
                for (MultipartFile file : files) {
                    log.debug("File Information");
                    log.debug("- file name: " + file.getOriginalFilename());
                    log.debug("- file size: " + file.getSize());
                    log.debug("- content type: " + file.getContentType());
                }
            }
        }
    }
	... (생략) ...

테스트

글쓰기 페이지에서 파일 첨부 후 저장했을 때 업로드한 파일 정보가 출력되는 것을 확인

파일 정보를 저장할 DTO를 생성

package board.dto;

import lombok.Data;

@Data
public class BoardFileDto {
    private int idx;
    private int boardIdx;
    private String originalFileName;
    private String storedFilePath;
    private String fileSize;
}

파일 처리를 위한 유틸리티 클래스를 생성

package board.common;

import java.io.File;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;

import board.dto.BoardFileDto;

@Component
public class FileUtils {
    @Value("${spring.servlet.multipart.location}")
    private String uploadDir;
    
    // 요청을 통해서 전달받은 파일을 저장하고, 파일 정보를 반환하는 메서드 
    public List<BoardFileDto> parseFileInfo(int boardIdx, MultipartHttpServletRequest request) throws Exception {
        if (ObjectUtils.isEmpty(request)) {
            return null;
        }
        
        List<BoardFileDto> fileInfoList = new ArrayList<>();
        
        // 파일을 저장할 디렉터리를 설정
        DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyyMMdd");
        ZonedDateTime now = ZonedDateTime.now();
        String storedDir = uploadDir + "images\\" + now.format(dtf);
        File fileDir = new File(storedDir);
        if (!fileDir.exists()) {
            fileDir.mkdirs();
        }
        
        // 업로드 파일 데이터를 디렉터리에 저장하고 정보를 리스트에 저장 
        Iterator<String> fileTagNames = request.getFileNames();
        while(fileTagNames.hasNext()) {
            String fileTagName = fileTagNames.next();
            List<MultipartFile> files = request.getFiles(fileTagName);
            for (MultipartFile file : files) {
                String originalFileExtension = "";
                
                // 파일 확장자를 ContentType에 맞춰서 지정
                if (!file.isEmpty()) {
                    String contentType = file.getContentType();
                    if (ObjectUtils.isEmpty(contentType)) {
                        break;
                    } else {
                        if (contentType.contains("image/jpeg")) {
                            originalFileExtension = ".jpg";
                        } else if (contentType.contains("image/png")) {
                            originalFileExtension = ".png";
                        } else if (contentType.contains("image/gif")) {
                            originalFileExtension = ".gif";
                        } else {
                            break;
                        }
                    }
                    
                    // 저장에 사용할 파일 이름을 조합
                    String storedFileName = Long.toString(System.nanoTime()) + originalFileExtension;
                    String storedFilePath = storedDir + "\\" + storedFileName;

                    
                    // 파일 정보를 리스트에 저장 
                    BoardFileDto dto = new BoardFileDto();
                    dto.setBoardIdx(boardIdx);
                    dto.setFileSize(Long.toString(file.getSize()));
                    dto.setOriginalFileName(file.getOriginalFilename());
                    dto.setStoredFilePath(storedFilePath);
                    fileInfoList.add(dto);
                    
                    // 파일 저장
                    fileDir = new File(storedFilePath);
                    file.transferTo(fileDir);                    
                }
            }
        }
        
        return fileInfoList;
    }
}

BoardServiceImpl

@Slf4j
@Service
public class BoardServiceImpl implements BoardService {
    @Autowired
    private BoardMapper boardMapper;
    
    @Autowired
    private FileUtils fileUtils;
    
    @Override
    public List<BoardDto> selectBoardList() {
        return boardMapper.selectBoardList();
    }

    @Override
    public void insertBoard(BoardDto boardDto, MultipartHttpServletRequest request) {
        // 로그인한 사용자를 글쓴이로 설정
        // TODO. 로그인한 사용자의 ID로 변경
        // boardDto.setCreatedId("hong");
        // boardMapper.insertBoard(boardDto);
        
        try {
            List<BoardFileDto> fileInfoList = fileUtils.parseFileInfo(100, request);
        } catch(Exception e) {
            
        }
           
    }
    ...(생략)...

0개의 댓글

관련 채용 정보