[LG U+ 유레카 3기] Spring MVC + JSP + MyBatis 프로젝트 정리
2025. 11. 17. 10:02ㆍJava/Spring
일주일 실습 총정리 (회원/로그인 → 게시판 CRUD·페이징 → 예외/에러 처리 → 트랜잭션 롤백 전략)
Spring MVC + MyBatis — 일주일 실습 총정리
이 글은 지난 일주일간 진행한 실습을 프로젝트 단위로 한 번에 묶은 기록이다.
Spring MVC + MyBatis + JSP + Ajax 조합으로 회원가입·로그인 → 게시판 CRUD/페이징 → 예외/에러 처리 아키텍처 → 트랜잭션 롤백 전략까지 순서대로 구축했다.
핵심은 요청-데이터-응답을 한 선으로 그리기
1. 프로젝트 구조와 흐름
- Controller : HTTP 엔드포인트, 파라미터 바인딩, 세션 접근 최소화, JSON/JSP 응답 결정
- Service : 비즈니스 규칙(페이징, sameUser, 권한), 트랜잭션 경계, 실패 응답 규격화(ResultDto)
- DAO(@Mapper) : MyBatis 인터페이스(쿼리 호출), SQL은 XML로 분리
- Mapper.xml :
<select/insert/update/delete>+ 바인딩 파라미터 - DTO :
UserDto, BoardDto, BoardParamDto, BoardResultDto - common :
LoginInterceptor,GlobalExceptionHandler,WebMvcConfig

// 요청 흐름(페이지):
브라우저(URL/링크) → DispatcherServlet → HandlerMapping → Controller
→ Service(비즈니스/트랜잭션) → Dao(MyBatis) → DB
→ (Model) → ViewResolver → JSP 렌더링
// 요청 흐름(Ajax/JSON):
브라우저(fetch) → Controller(@ResponseBody)
→ Service → Dao → DB → ResultDto(JSON) → 브라우저 렌더링

2. 회원가입 · 로그인 (핵심)
2.1 register.jsp → /users/register
// register.jsp (요약)
const p = new URLSearchParams();
p.append("userName", userName);
p.append("userPassword", userPassword);
p.append("userEmail", userEmail);
const res = await fetch("/users/register", { method:"post", body:p, headers:{ ajax:"true" } });
const data = await res.json();
if (data.result === "success") location.href = "/pages/login";
// UserController
@PostMapping("/users/register")
@ResponseBody
public UserResultDto register(UserDto userDto) {
return userService.registerUser(userDto); // DTO 데이터 바인딩
}
// UserServiceImpl
@Override
@Transactional
public UserResultDto registerUser(UserDto dto) {
int ret = userDao.registerUser(dto);
UserResultDto out = new UserResultDto();
out.setResult(ret == 1 ? "success" : "fail");
return out;
}
<!-- UserMapper.xml -->
<insert id="registerUser" parameterType="UserDto">
INSERT INTO users(user_name, user_password, user_email)
VALUES(#{userName}, #{userPassword}, #{userEmail})
</insert>
2.2 로그인 : Optional + 세션
// LoginController
@PostMapping("/auth/login")
@ResponseBody
public Map<String,String> login(UserDto dto, HttpSession session) {
Map<String,String> map = new HashMap<>();
loginService.login(dto).ifPresentOrElse(
user -> { user.setUserPassword(null); session.setAttribute("userDto", user); map.put("result","success"); },
() -> { map.put("result","fail"); }
);
return map;
}
핵심: 컨트롤러는 얇게, 서비스는 도메인 규칙과 결과 포장을 담당. DB 코드는 전부 DAO/Mapper.xml로 분리.
3. 게시판 CRUD · 페이징 (핵심)
3.1 요청/응답 규격화 : BoardParamDto · BoardResultDto
// BoardParamDto : 요청 파라미터
public class BoardParamDto {
private int page = 1, size = 10;
private String key, word;
public int getOffset() { return (page - 1) * size; }
// getters/setters...
}
// BoardResultDto : 응답(목록+페이징 메타)
public class BoardResultDto {
private List<BoardDto> list;
private int page, size, totalCount, totalPage;
private String result, message;
}
// Controller
@PostMapping("/boards")
@ResponseBody
public BoardResultDto list(BoardParamDto param, HttpSession session) {
return boardService.listBoards(param);
}
// Service
@Override
@Transactional(readOnly = true)
public BoardResultDto listBoards(BoardParamDto p) {
BoardResultDto out = new BoardResultDto();
try {
int total = boardDao.countBoard(p);
List<BoardDto> list = boardDao.listBoard(p);
out.setList(list);
out.setPage(p.getPage());
out.setSize(p.getSize());
out.setTotalCount(total);
out.setTotalPage((int)Math.ceil(total/(double)p.getSize()));
out.setResult("success");
} catch (Exception e) {
out.setResult("fail");
out.setMessage("목록 조회 중 오류가 발생했습니다.");
}
return out;
}
<!-- BoardMapper.xml -->
<select id="countBoard" parameterType="BoardParamDto" resultType="int">
SELECT COUNT(*) FROM board
<where>
<if test="key == 'title' and word != null">
AND title LIKE CONCAT('%', #{word}, '%')
</if>
</where>
</select>
<select id="listBoard" parameterType="BoardParamDto" resultType="BoardDto">
SELECT board_id, title, writer, hit, created_at
FROM board
<where>
<if test="key == 'title' and word != null">
AND title LIKE CONCAT('%', #{word}, '%')
</if>
</where>
ORDER BY board_id DESC
LIMIT #{size} OFFSET #{offset}
</select>
3.2 상세/등록/수정/삭제 & sameUser
// insert
@PostMapping("/boards/write")
@ResponseBody
public BoardResultDto write(BoardDto dto, HttpSession session) {
UserDto u = (UserDto) session.getAttribute("userDto");
dto.setWriter(u.getUserName());
dto.setWriterEmail(u.getUserEmail());
return boardService.writeBoard(dto);
}
// update : sameUser 정책
@Override
@Transactional
public BoardResultDto updateBoard(BoardDto dto, UserDto login) {
BoardResultDto out = new BoardResultDto();
BoardDto origin = boardDao.selectBoard(dto.getBoardId());
if (origin == null) { out.setResult("notfound"); return out; }
if (!origin.getWriterEmail().equals(login.getUserEmail())) { out.setResult("forbidden"); return out; }
out.setResult(boardDao.updateBoard(dto) == 1 ? "success" : "fail");
return out;
}
핵심: 요청 파라미터는 DTO로 묶고, 응답도 ResultDto로 표준화. 권한 판단(sameUser)은 Service가 맡아 도메인 규칙을 응집.
4. 예외/에러 처리 아키텍처
4.1 /error 루프 방지 + Ajax/페이지 분리
// WebMvcConfig : /error 제외
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry r) {
r.addInterceptor(new LoginInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/error", "/pages/login", "/auth/login", "/users/register");
}
}
// LoginInterceptor : Ajax면 JSON, 페이지면 redirect
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object h) throws Exception {
boolean isAjax = "true".equals(req.getHeader("ajax"));
UserDto user = (UserDto) req.getSession().getAttribute("userDto");
if (user != null) return true;
if (isAjax) {
res.setContentType("application/json; charset=UTF-8");
res.getWriter().write("{\"result\":\"login\"}");
} else {
res.sendRedirect("/pages/login");
}
return false;
}
}
4.2 WhiteLabel 대체 : error.jsp
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" isErrorPage="true" %>
<h3>문제가 발생했습니다.</h3>
메시지: ${exception != null ? exception.message : '알 수 없는 오류'}<br/>
<% Object uri = request.getAttribute("jakarta.servlet.error.request_uri");
if (uri == null) uri = request.getAttribute("javax.servlet.error.request_uri"); %>
요청 경로: <%= uri %>
4.3 전역 예외 핸들러 : 페이지/JSON 분기
@ControllerAdvice
public class GlobalExceptionHandler {
private boolean isAjax(HttpServletRequest req) { return "true".equals(req.getHeader("ajax")); }
@ExceptionHandler(Exception.class)
public Object handleAny(HttpServletRequest req, Exception ex) {
if (isAjax(req)) {
Map<String,Object> body = new LinkedHashMap<>();
body.put("result","fail");
body.put("message", ex.getMessage());
body.put("path", req.getRequestURI());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
ModelAndView mv = new ModelAndView("error");
mv.addObject("exception", ex);
mv.addObject("url", req.getRequestURI());
return mv;
}
}
핵심: 페이지 에러는 error.jsp, 데이터 에러는 JSON {result, message, path}로 통일. 인터셉터에서 /error를 제외해 루프를 차단.
5. 트랜잭션 롤백 전략
5.1 기본 규칙
- @Transactional 경계는 프록시가 만든다(주로 Service public 메서드).
- Unchecked(Runtime) 예외 전파 시 롤백, Checked는 기본적으로 롤백 아님(필요 시
rollbackFor지정). - 예외를 삼키면 커밋되므로, 삼키되 롤백하려면
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().
5.2 6가지 케이스 정답표
| 케이스 | 상황 | 롤백 | 설명 |
|---|---|---|---|
| 1 | @Transactional 없음, try-catch 없음 | ❌ | 트랜잭션 없음(자동 커밋) |
| 2 | @Transactional 없음, try-catch 있음(삼킴) | ❌ | 동일 |
| 3 | @Transactional 있음, try-catch 없음(전파) | ✅ | 런타임 예외 전파 → 프록시 롤백 |
| 4 | @Transactional 있음, try-catch 있음(삼킴) | ❌ | 예외 미전달 → 커밋 |
| 5 | @Transactional 있음, catch에서 rethrow | ✅ | 전파 → 롤백(전역 핸들러가 응답) |
| 6 | @Transactional 있음, catch에서 setRollbackOnly() | ✅ | 예외 소비 + 롤백 지시 → JSON 표준 응답 |
// (5) 예외 전파로 롤백
@Transactional
public void placeOrder(OrderCmd cmd) {
dao.insertOrder(cmd);
if (invalid(cmd)) throw new RuntimeException("주문 검증 실패");
dao.insertOrderLines(cmd.lines());
}
// (6) 예외 소비 + 롤백 지시
import org.springframework.transaction.interceptor.TransactionAspectSupport;
@Transactional
public BoardResultDto listBoard(BoardParamDto p) {
BoardResultDto out = new BoardResultDto();
try {
int total = boardDao.countBoard(p);
out.setList(boardDao.listBoard(p));
out.setTotalCount(total);
out.setResult("success");
} catch (Exception e) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
out.setResult("fail");
out.setMessage("목록 조회 중 오류가 발생했습니다.");
}
return out;
}
5.3 주의: 프록시 경계(Self-invocation)와 전파
- 같은 클래스 내부
this.inner()호출은 프록시를 안 거쳐@Transactional이 적용되지 않는다. - 안쪽에서
setRollbackOnly()로 롤백 마킹하면 바깥 커밋 시UnexpectedRollbackException가능. - 격리가 필요하면
Propagation.REQUIRES_NEW로 새로운 트랜잭션을 연다.
6. Ajax 공통 처리 (클라이언트)
async function api(url, params) {
const res = await fetch(url, { method:"post", body:params, headers:{ ajax:"true" } });
const data = await res.json();
if (data.result === "login") {
location.href = "/pages/login";
return;
}
if (data.result === "fail") {
alert(data.message ?? "처리 중 오류가 발생했습니다.");
throw new Error(data.message || "fail");
}
return data;
}
핵심: 서버는 ResultDto({result, message, ...})로 응답을 표준화, 클라이언트는 한 곳에서 에러 UX를 통일.
7. 결과 (미니 프로젝트 형태로 완성된 상태)
- 회원가입/로그인 + 세션/인터셉터로 보안 기본 마련
- 게시판 CRUD/검색/페이징을 DTO 규격 기반으로 일관 처리
- 페이지/데이터 에러 경로를 분리하고 error.jsp와 전역 핸들러(JSON)로 관리
- 트랜잭션 실패 정책을 예외 전파 vs 롤백 지시로 구분해 실무 가이드 완성
8. 용어 · 키워드 사전
- DispatcherServlet : 스프링 MVC 프론트 컨트롤러. 요청을 컨트롤러로 라우팅
- HandlerMapping / HandlerAdapter : 어떤 컨트롤러/메서드를 호출할지 결정/호출 보조
- ViewResolver : 논리 뷰 이름 → JSP 등 실제 뷰로 해석
- DTO : 계층 간 데이터 전달 전용 객체. 요청/응답/도메인 분리
- DAO : DB 접근 전담. MyBatis @Mapper 인터페이스
- MyBatis Mapper.xml : SQL과 파라미터/결과 매핑을 XML로 정의
- LoginInterceptor : 컨트롤러 이전에 로그인 검사를 처리하는 문지기
- GlobalExceptionHandler : 전역 예외 처리. 페이지/JSON 분리 대응
- Whitelabel Error Page : 스프링 부트 기본 에러 화면. error.jsp로 대체
- @Transactional : 프록시 기반 트랜잭션 경계. 런타임 예외 전파 시 롤백
- TransactionAspectSupport.setRollbackOnly() : 예외는 삼키되 롤백만 지시
- Propagation.REQUIRES_NEW : 새로운 트랜잭션을 열어 격리
- UnexpectedRollbackException : 내부 롤백 마킹 상태에서 바깥이 커밋하려 할 때 발생
10. 마무리
이번 주 실습은 단순 CRUD를 넘어, 조회수 증가 , (인터셉터, 에러 경로, JSON 표준, 트랜잭션 정책)을 한 층 위에서 설계했다.
'Java > Spring' 카테고리의 다른 글
| [LG U+ 유레카 3기] Spring MVC + CORS 실습 (0) | 2025.11.25 |
|---|---|
| [LG U+ 유레카 3기]Spring Boot + JDBC -> MyBatis 전환 실습 (0) | 2025.11.06 |
| [LG U+ 유레카 3기] Spring Boot MVC 도서 관리 시스템 실습 (0) | 2025.11.05 |
| [LG U+ 유레카3기]Spring MVC | HttpSession 로그인 → 유지 → 로그아웃 실습 정리 (0) | 2025.11.05 |
| [LG U+ 유레카 3기]Spring MVC | 요청 바인딩 + View/Model/Redirect 실습 정리 (0) | 2025.11.05 |