11/13) 조회수, 예외 처리, 트랜잭션

AI·2025년 11월 13일

조회수

누르면 조회수 상승(데이터 왜곡 발생) vs 1명의 사용자는 조회수 1만 증가

데이터 형태

    create table board_user_read (
      board_id int not null,
      user_seq int not null,
      key board_user_read_fk_02_idx (user_seq),
      key board_user_read_fk_01_idx (board_id),
      constraint board_user_read_fk_01 foreign key (board_id) references board (board_id),
      constraint board_user_read_fk_02 foreign key (user_seq) references users (user_seq)
    ) ;

dao에 추가

	// 조회수 처리
	// 현재 사용자가 현재 게시글을 이전에 읽었는지 판단
	int countBoardUserRead(BoardParamDto boardParamDto);
	// 현재 사용자가 현재 게시글을 읽었음을 기록
	int insertBoardUserRead(BoardParamDto boardParamDto);
	// 현재 게시글의 조회수 증가
	int updateBoardUserRead(int boardId);

mapper에 추가

	<!-- 조회수 처리 -->
	<select id="countBoardUserRead" parameterType = "com.mycom.myapp.board.dto.BoardParamDto">
		select count(*)
			from board_user_read
			where board_id = #{boardId}
				and user_seq = #{userSeq};
	</select>
	
	<insert id="insertBoardUserRead" parameterType = "com.mycom.myapp.board.dto.BoardParamDto">
		insert into board_user_read( board_id,   user_seq)
							values (#{boardId}, #{userSeq});
	</insert>
	
	<update id="updateBoardUserRead" parameterType = "int">
		update board
			set read_count = read_count + 1
			where board_id = #{boardId};
	</update>

조회수 증가는 detail과 같이 작동하니까 service에 구현을 해서 controller가 관리하게 할 필요는 없음

boardServiceImpl 조회수 처리

	@Override
	public BoardResultDto detailBoard(BoardParamDto boardParamDto) {
		BoardResultDto boardResultDto = new BoardResultDto();
		
		try {
			// 조회수 처리
			int userReadCnt = boardDao.countBoardUserRead(boardParamDto);
			
			if(userReadCnt == 0) { // 현재 사용자가 현재 게시글을 처음 조회하는 경우
				// 조회 표시
				boardDao.insertBoardUserRead(boardParamDto);
				// 게시글 조회수 증가
				boardDao.updateBoardUserRead(boardParamDto.getBoardId());
			}
			
			// 게시글 조회 코드
		
		return boardResultDto;
	}

번외)
어디를 가나 쿼리 잘 작성하는 사람이 필요하다고 함. 테이블 구조를 잘 알아야 함. ex. 휴가 가는 것만 해도 10번 넘게 작동한다고 함. + 기술, 도메인에 대한 비즈니스 개념
비즈니스 로직을 복잡하게 해보기.

예외 처리


ajax 에러가 큰 문제

Ajax(Asynchronous JavaScript and XML)란?

기술 이름이 아니라, "웹페이지가 새로고침(F5) 없이 서버와 통신하는 기술

mvc로 문제 발생할 경우, 백 코드 자체를 수정해야 함 => 사용을 안하고 spring boot를 사용함

실습

로그인 에러 만들기

	@GetMapping("/pages/login")
	public String login() {
		// NullPointerException 발생
		String s = null;
		s.length();
		
		return "login";
	}

실행시 다음과 같은 에러 발생

LoginInterceptor Requested URI >> /error
LoginInterceptor >> page request 거절

Spring FrameWork(SF)가 예외 발생했기 때문에 /error forwarding이 반복 => Interceptor 정책 /error 추가

	@Override
	public void addInterceptors(InterceptorRegistry registry) {

		registry.addInterceptor(loginInterceptor)
		.addPathPatterns("/**")
		.excludePathPatterns(
				// 다른 링크들
				"/error" // 예외 발생시 Spring FrameWork가 forwarding하는 url 추가
				);

	}


에러가 반복이 되지 않고 Whitelabel Error Page가 뜸
=> error.jsp 만들어 줌 => Whitelabel Error Page대신 error.jsp로 이동을 함

error.jsp를 jsp의 예외 처리 페이지로 변경을 위해 isErrorPage="true"로 파일 수정

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" isErrorPage="true"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
<title>error.jsp</title>
</head>
<body>
	<h1>error.jsp</h1>
	<p> request url : <%= request.getRequestURI() %></p>
	<p> error is <%= exception.getMessage() %></p>
</body>
</html>

url을 확인을 해봤는데 error.jsp로 자신임

백엔드 로그에 ERROR가 뜬 것은 SF가 error 자체를 직접적으로 핸들링을 하지 못했다는 것 => 해결 방법 2가지
1. 개별 Controller Handler가 처리 - 방법 2보다 우선 순위는 더 높음(log 찍어보면 알 수 있음)
2. 전역 Handler가 처리

1번 방식

PageController에 ExceptionHandler 추가

	// SF 예외 처리자가 처리
	// 예외가 발생하면 이 메소드에서 처리
	@ExceptionHandler(Exception.class)
	public String pageExceptionHandler(Exception ex, Model model, HttpServletRequest request) {
		model.addAttribute("exception", ex);
		model.addAttribute("requestURI", request.getRequestURI());
		
		return "error";
	}

=>
error.jsp가 조금 더 편하게 데이터 가능
request를 통해 프론트가 요청한 url 확인

<!-- @ExceptionHandler로 테스트 -->
	<p> request url : <%= request.getAttribute("requestURI") %></p>
	<p> error is <%= exception.getMessage() %></p>

백엔드 로그에 WARN이 뜸 - ExceptionHandlerExceptionResolver가 핸들링 함

2번 방식

package com.mycom.myapp.config;

import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

import jakarta.servlet.http.HttpServletRequest;

@ControllerAdvice // 개별 컨트롤러가 처리하지 않는 모든 예외 담당
public class GlobalExcetionHandler {

	@ExceptionHandler(Exception.class)
	public String exceptionHandler(Exception ex, Model model, HttpServletRequest request) {
		model.addAttribute("exception", ex);
		model.addAttribute("requestURI", request.getRequestURI());
		
		return "error";
	}
}

전역 Handler 추가 => 1번 방식과 동일한 결과

결론

개별 Handler에서 발생할 수 있는 세부적인 에러를 설정하고, 처리가 안된다면, 전역 Handler로 처리하도록 설정

데이터 요청, 처리 과정에서 예외 발생 - listBoard 예외 처리

1. try-catch 밖

	@Override
	public BoardResultDto listBoard(BoardParamDto boardParamDto) {
		BoardResultDto boardResultDto = new BoardResultDto();
		
        String s = null;
		s.length();
        
		try {
			//
		}catch(Exception e) {
			//
		}
		
		return boardResultDto;
	}

백엔드 응답에 json 전달
ERROR => handler 추가 => WARN
error.jsp응답(html) 전달 => 프론트는 js로 처리 X

2. try-catch 안
catch 블럭 내용 실행 => 로그 및 응답 정상. 단, result=fail
handler를 넣어도 catch문 실행

결론

Spring MVC + JSP + AJAX + error.jsp 데이터 요청 오류 처리 예외 대응 방안

  1. try-catch로 모든 예외 처리 => 비효율적, 단순 무식한 처리
  2. Spring MVC의 표준 예외 처리(error.jsp) => 데이터 요청 처리 곤란함
  3. 하이브리드 = try-catch 최적화, ~ResultDto를 통한 Front-Back 응답구조 규격
    => 페이지 이동 시 예외는 개별 controller의 handler 이용, try-catch밖 예외는 global handler(error.jsp 대신 result:fail 응답)

// 데이터 요청 대응
@ControllerAdvice // 개별 컨트롤러가 처리하지 않는 모든 예외 담당
@ResponseBody // html 형태 아님을 명시 => json 같은 형태로 사용
public class GlobalExcetionHandler {

	@ExceptionHandler(Exception.class)
	public Map<String, String> exceptionHandler(Exception ex) {
		System.out.println("exceptionHandler - Exception");
		Map<String, String> map = new HashMap<>();
		map.put("result","fail"); // front와 다양한 대화를 위해 map 형태
		
		return map;
	}
}

throws를 넣으면, try-catch가 없는 거처럼 처리됨?

트랜잭션

오전에 진행한 detailBoard에 insertBoardUserRead와 updateBoardUserRead사이에 에러를 발생 시킴

1. try-catch 안에서

insert 작동하고 에러 발생(rollback X)

2. try-catch 없이

1번과 동일하게 rollback 안됨
=> SF가 자동으로 트랜잭션 처리를 하지 않는다.

@Transactional 추가

1. try-catch 없이

=> 특정 메소드 단위로 트랜잭션 처리 가능 = Spring AOP로 처리
=> @Transactional을 메소드에 선언 => AOP 매니저가 pointcut으로 관리(proxy)

@Transactional

=> rollback 수행함
기본적으로 작동방식은 Controller -> ServiceImpl -> 메소드다.
@Transactional를 붙이면, Controller -> ServiceImpl의 proxy -> 메소드를 호출하게 되는거고 setAutoCommit = false로 설정하기에 문제가 생기면, rollback 작동하는 것이다.

2. try-catch 안에서

하지만, @Transactional를 붙여도 try-catch문에서 작동을 한다면, rollback이 되지 않는다.
proxy가 만들어졌지만, 인식을 못하기 때문이다.
Controller -> ServiceImpl의 proxy -> 메소드 -> 에러가 발생을 안해서(proxy 안에서 catch로 예외를 잡았기에 밖에서는 에러가 발생이 안되었다고 판단) commit이 되버리게 되는거다.

3. try-catch에 throw 추가

=> throw를 추가해서 에러가 발생된 걸로 처리하기 -> proxy가 에러가 발생했다고 전달 -> rollback 작동됨

		}catch(Exception e) {
			e.printStackTrace();
			boardResultDto.setResult("fail");
			
			throw new RuntimeException("~~~");
		}

정상적인 프론트와 대화를 위해서는 전역 handler가 있어야 함. 프론트에 result:fail을 보내야 하기 때문
=> proxy에게 예외를 전달하는게 아니라 정책을 전달함

		}catch(Exception e) {
			e.printStackTrace();
			boardResultDto.setResult("fail");
			
//			throw new RuntimeException("~~~");
			TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
		}

이렇게 하면, 전역 handler가 없어도 잘 작동함.

결론

try-catch 미사용 => front에 대한 응답 구성 어려움
try-catch 사용시에는 => TransactionAspectSupport 이용

주의) 클래스 레벨의 @Transactional 사용 지양 = 모든 메소드가 proxy만들어지고 관리 코드가 동작하기 때문
개별 메소드에 @Transactional 방법도 있지만, AOP나 Configuration을 통해 일괄 트랜잭션 통제 가능

추가적인 요소)

조회수 처리 >

  • 비즈니스 로직과 강하게 연결되어 논리적, 시간적 응집도 같은 수준이다. 동시에 실행되기에 묶어 둔 것이다.
  • 조회수 증가를 보는 것은 중요하지 않기에 비동기 처리로 @Async를 적용할 수 있다. 대신 새롭게 클래스를 만들어서 작동시켜야 한다.
  • 인메모리 캐시를 도입하여 1차 기록을 하고 특정 시간마다 업데이트를 하여 DB의 부하를 줄일 수 있음

예외 처리 >
@ControllerAdvice + @ResponseBody = @RestControllerAdvice

트랜잭션 >
@Transactional(propagation = ...)
private 메소드나 self-invocation(내부 메소드 호출)에는 @Transactional이 (AOP Proxy 문제로) 작동하지 않음 => 별도로 분리하거나 self-injection

0개의 댓글