[LG U+ 유레카 3기] Spring MVC + JSP + MyBatis 프로젝트 정리

2025. 11. 17. 10:02Java/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) → 브라우저 렌더링

DB 테이블과 컬럼명

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 표준, 트랜잭션 정책)을 한 층 위에서 설계했다.