Spring_09) Spring Boot - View Resolver와 AJAX 비동기 통신
2025. 11. 7. 09:22

Spring Boot는 전통적인 서버 사이드 렌더링과 현대적인 비동기 통신을 모두 지원한다. View Resolver는 JSP와 같은 뷰 템플릿을 자동으로 찾아 렌더링하며, @ResponseBody는 AJAX 비동기 통신을 위해 JSON 데이터를 직접 응답한다. 두 방식은 각각의 사용 목적이 명확하며, 프로젝트의 요구사항에 따라 적절히 선택하여 활용할 수 있다.


<View Resolver 설정>

Spring Boot는 기본적으로 Thymeleaf를 뷰 템플릿 엔진으로 사용하지만, JSP를 사용하기 위해서는 View Resolver를 별도로 설정해야 한다. View Resolver는 Controller에서 반환된 뷰 이름을 실제 JSP 파일 경로로 변환하는 역할을 수행한다.

- application.properties 설정

application.properties 파일에서 접두사(prefix)와 접미사(suffix)를 설정하여 View Resolver를 구성한다.

# JSP view resolver 설정
spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp

이 설정을 통해 Controller에서 "board/listView"라는 뷰 이름을 반환하면, Spring Boot는 자동으로 /WEB-INF/views/board/listView.jsp 파일을 찾아 렌더링한다. /WEB-INF/ 디렉토리는 외부에서 직접 접근할 수 없는 보안 영역으로, Controller를 통해서만 접근 가능하다는 장점이 있다.

- Controller에서 View 반환

Controller 메서드는 문자열 형태로 뷰 이름을 반환하며, Model 객체를 통해 뷰에 전달할 데이터를 추가한다.

@Controller
public class BoardController {

    private final BoardService boardService;

    @Autowired
    public BoardController(BoardService boardService) {
        this.boardService = boardService;
    }

    @GetMapping("list.bo")
    public String selectBoardList(@RequestParam(value = "cpage", defaultValue = "1") int currentPage, 
                                   Model model) {
        Map<String, Object> result = boardService.getBoardList(currentPage);

        model.addAttribute("list", result.get("list"));
        model.addAttribute("pi", result.get("pi"));

        return "board/listView";
    }

    @GetMapping("/detail.bo")
    public String detailBoard(@RequestParam(value = "bno", required = true) int boardNo, 
                              Model model) {
        Map<String,Object> result = boardService.getBoardByIdWithCount(boardNo);

        if(result.get("board") != null){
            model.addAttribute("board", result.get("board"));
            model.addAttribute("attachment", result.get("attachment"));
            return "board/detailView";
        } else {
            model.addAttribute("errorMsg", "게시글 조회 실패");
            return "common/error";
        }
    }
}

View Resolver의 동작 과정은 다음과 같다. 첫째, Controller 메서드가 뷰 이름을 문자열로 반환한다. 둘째, View Resolver가 설정된 prefix와 suffix를 결합하여 실제 JSP 파일 경로를 생성한다. 셋째, 해당 경로의 JSP 파일을 찾아 렌더링하고 HTML 응답을 생성한다. 이러한 방식은 전체 페이지를 렌더링할 때 적합하다.


<@ResponseBody를 활용한 비동기 통신>

전통적인 동기 방식은 매번 전체 페이지를 새로고침해야 하지만, @ResponseBody를 사용하면 필요한 데이터만 JSON 형태로 전달하여 페이지의 일부분만 동적으로 업데이트할 수 있다.

- @ResponseBody의 개념

@ResponseBody 어노테이션은 메서드의 반환값을 HTTP 응답 본문(Response Body)에 직접 작성하도록 지정한다. 일반적인 Controller 메서드는 View Resolver를 거쳐 JSP 페이지를 렌더링하지만, @ResponseBody가 선언된 메서드는 View Resolver를 거치지 않고 반환값을 바로 HTTP 응답 본문에 작성한다.

Spring Boot의 spring-boot-starter-web 의존성에는 Jackson 라이브러리가 기본적으로 포함되어 있다. Jackson은 Java 객체를 JSON 문자열로 자동 변환하는 역할을 수행하며, 개발자가 별도의 변환 로직을 작성할 필요가 없다.

- 기본 사용법

단순한 문자열을 반환하는 경우, 해당 문자열이 그대로 응답 본문에 작성된다.

@Controller
public class BoardController {

    @PostMapping("/rinsert.bo")
    @ResponseBody
    public String insertReply(@RequestParam("boardNo") int boardNo,
                              @RequestParam("content") String content,
                              HttpSession session) {
        Member m = (Member)session.getAttribute("loginMember");

        Reply reply = new Reply();
        reply.setRefBoardNo(boardNo);
        reply.setReplyContent(content);
        reply.setReplyWriter(m.getMemberNo());

        int result = boardService.insertReply(reply);

        return result > 0 ? "1" : "0";
    }

    @GetMapping("rdelete.bo")
    @ResponseBody
    public String deleteReply(@RequestParam("replyNo") int replyNo) {
        int result = boardService.removeReply(replyNo);
        return result > 0 ? "1" : "0";
    }
}

위 코드에서 insertReply 메서드는 댓글 작성 성공 시 "1", 실패 시 "0"을 문자열로 반환한다. 클라이언트는 이 응답을 받아 성공 여부를 판단하고 적절한 처리를 수행할 수 있다.

- 객체의 JSON 자동 변환

Jackson 라이브러리는 Java 컬렉션이나 객체를 자동으로 JSON 형식으로 변환한다. 이는 복잡한 데이터 구조를 클라이언트에 전달할 때 매우 효율적이다.

@GetMapping("/rlist.bo")
@ResponseBody
public List<Reply> selectReply(@RequestParam("boardNo") int boardNo) {
   return boardService.getReplyListByBoardNo(boardNo);
}

Reply VO 클래스:

@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class Reply {
    private int replyNo;
    private String replyContent;
    private int refBoardNo;
    private int replyWriter;
    private String createDate;
    private String status;
    private String memberId;
}

List<Reply> 객체가 반환되면 Jackson은 이를 다음과 같은 JSON 배열로 자동 변환한다.

JSON 변환 결과:

[
    {
        "replyNo": 1,
        "replyContent": "좋은 글 감사합니다!",
        "refBoardNo": 10,
        "replyWriter": 5,
        "createDate": "2024-01-15 14:30:00",
        "status": "Y",
        "memberId": "user01"
    },
    {
        "replyNo": 2,
        "replyContent": "유용한 정보네요",
        "refBoardNo": 10,
        "replyWriter": 7,
        "createDate": "2024-01-15 15:20:00",
        "status": "Y",
        "memberId": "user02"
    }
]

클라이언트는 이 JSON 데이터를 JavaScript로 파싱하여 동적으로 화면에 렌더링할 수 있다.


<AJAX를 활용한 비동기 댓글 시스템>

AJAX(Asynchronous JavaScript and XML)는 페이지 전체를 새로고침하지 않고 서버와 비동기적으로 데이터를 주고받는 기술이다. jQuery의 $.ajax() 메서드를 사용하면 간결하게 비동기 요청을 구현할 수 있다.

- 댓글 목록 조회

Controller:

@GetMapping("/rlist.bo")
@ResponseBody
public List<Reply> selectReply(@RequestParam("boardNo") int boardNo) {
   return boardService.getReplyListByBoardNo(boardNo);
}

JavaScript (jQuery):

function init(bno){
    getReplyList(bno, drawReplyList);
}

function getReplyList(bno, callback){
    $.ajax({
        url: "rlist.bo",
        dataType: "json",
        data: {
            boardNo : bno
        },
        success: function(res){
            callback(res, bno);
        },
        error: function(err){
            console.log("댓글 로드 ajax 실패");
        }
    })
}

function drawReplyList(replyList, bno){
    const replyContainer = document.querySelector("#reply-container");
    replyContainer.innerHTML = "";

    for(let r of replyList){
        const replyRow = document.createElement("tr");
        replyRow.innerHTML = "<td>" + r.memberId + "</td>" +
                             "<td class='text-start'>" + 
                                r.replyContent +
                                "<div class='small text-secondary mt-1'>" + r.createDate + "</div>" +
                             "</td>" + 
                             "<td><button class='btn btn-outline-danger btn-sm'>삭제</button></td>";

        let deleteBtn = replyRow.querySelector("button");
        deleteBtn.addEventListener("click", function(){
            deleteReply(r.replyNo, function(){
                getReplyList(bno, drawReplyList);
            });
        });

        replyContainer.appendChild(replyRow);
    }
}

JSP:

<div class="reply-section">
    <table class="reply-table">
        <thead>
            <tr>
                <th width="120">댓글작성</th>
                <c:choose>
                    <c:when test="${loginMember != null}">
                        <td>
                            <textarea id="reply-content" cols="50" rows="3"></textarea>
                        </td>
                        <td width="100">
                            <button class="btn btn-primary reply-btn" onclick="insertReply(${board.boardNo})">댓글등록</button>
                        </td>
                    </c:when>
                    <c:otherwise>
                        <td>
                            <textarea cols="50" rows="3" readonly>댓글등록은 로그인이 필요합니다.</textarea>
                        </td>
                        <td width="100">
                            <button class="btn btn-primary reply-btn" disabled>댓글등록</button>
                        </td>
                    </c:otherwise>
                </c:choose>
            </tr>
        </thead>
        <tbody id="reply-container">
        </tbody>
    </table>
</div>

위 코드에서 콜백 함수 패턴을 활용하여 비동기 작업의 순서를 제어한다. getReplyList 함수는 서버로부터 댓글 목록을 가져온 후 drawReplyList 콜백 함수를 호출하여 화면에 렌더링한다. 이러한 패턴은 코드의 재사용성과 가독성을 높인다.

- 댓글 작성

Controller:

@PostMapping("/rinsert.bo")
@ResponseBody
public String insertReply(@RequestParam("boardNo") int boardNo,
                          @RequestParam("content") String content,
                          HttpSession session) {
    Member m = (Member)session.getAttribute("loginMember");

    Reply reply = new Reply();
    reply.setRefBoardNo(boardNo);
    reply.setReplyContent(content);
    reply.setReplyWriter(m.getMemberNo());

    int result = boardService.insertReply(reply);

    return result > 0 ? "1" : "0";
}

JavaScript:

function insertReply(bno){
    const contentInput = document.querySelector("#reply-content");

    $.ajax({
        url: "rinsert.bo",
        type: "post",
        data: {
            boardNo : bno, 
            content: contentInput.value
        },
        success: function(res){
            if(res === "1") {
                contentInput.value = "";
                getReplyList(bno, drawReplyList);
            }
        },
        error: function(err){
            console.log("댓글 작성 ajax 실패");
        }
    })
}

댓글 작성이 성공하면 입력 필드를 초기화하고 getReplyList를 호출하여 업데이트된 댓글 목록을 다시 가져온다. 이 과정에서 페이지 전체가 아닌 댓글 영역만 갱신된다.

- 댓글 삭제

Controller:

@GetMapping("rdelete.bo")
@ResponseBody
public String deleteReply(@RequestParam("replyNo") int replyNo) {
    int result = boardService.removeReply(replyNo);
    return result > 0 ? "1" : "0";
}

JavaScript:

function deleteReply(replyNo, callback){
    $.ajax({
        url: "rdelete.bo",
        data: {
            replyNo : replyNo, 
        },
        success: function(res){
            if(res === "1")
                callback();
        },
        error: function(err){
            console.log("댓글 삭제 ajax 실패");
        }
    })
}

삭제 작업이 성공하면 콜백 함수를 실행하여 댓글 목록을 다시 불러온다. 각 함수가 독립적으로 동작하면서도 콜백을 통해 연결되어 있어 코드의 유지보수가 용이하다.


<동기 방식과 비동기 방식의 차이>

- 전통적인 동기 방식

동기 방식에서는 사용자의 요청마다 서버가 전체 페이지를 새로 렌더링하여 응답한다.

@PostMapping("/insert.bo")
public String insertBoard(Board board, Model model) {
    int result = boardService.insertBoard(board);

    if(result > 0){
        return "redirect:/list.bo";
    } else {
        model.addAttribute("errorMsg", "게시글 등록 실패");
        return "common/error";
    }
}

동기 방식은 구현이 간단하고 직관적이지만 몇 가지 단점이 있다. 첫째, 페이지 전체가 새로고침되어 화면이 깜빡이는 현상이 발생한다. 둘째, 변경되지 않은 데이터까지 모두 다시 전송하므로 네트워크 자원이 낭비된다. 셋째, 사용자는 페이지가 로드될 때까지 대기해야 하므로 사용자 경험이 저하된다.

- 비동기 방식

비동기 방식은 필요한 데이터만 JSON 형태로 주고받아 페이지의 일부만 업데이트한다.

@PostMapping("/rinsert.bo")
@ResponseBody
public String insertReply(@RequestParam("boardNo") int boardNo,
                          @RequestParam("content") String content) {
    int result = boardService.insertReply(reply);
    return result > 0 ? "1" : "0";
}

비동기 방식은 여러 장점을 제공한다. 첫째, 페이지 새로고침 없이 필요한 부분만 갱신하여 부드러운 사용자 경험을 제공한다. 둘째, 필요한 데이터만 전송하므로 네트워크 부하가 감소한다. 셋째, 응답 속도가 빨라 실시간성이 중요한 기능 구현에 적합하다. 넷째, 서버 부하가 줄어들어 더 많은 요청을 처리할 수 있다.


<실무 활용 패턴>

- 콜백 함수 패턴

콜백 함수는 다른 함수의 인자로 전달되어 특정 시점에 실행되는 함수이다. 비동기 작업에서 작업 완료 후 수행할 동작을 지정할 때 유용하다.

function getReplyList(bno, callback){
    $.ajax({
        url: "rlist.bo",
        dataType: "json",
        data: { boardNo : bno },
        success: function(res){
            callback(res, bno);
        }
    })
}

이러한 패턴은 코드의 재사용성을 높이고 비동기 작업의 순서를 명확하게 제어할 수 있다. 예를 들어, 댓글 작성 후 목록을 갱신하거나 삭제 후 목록을 갱신하는 등의 작업을 콜백으로 처리할 수 있다.

- 응답 데이터 형식 선택

간단한 성공/실패 여부만 전달할 때는 문자열을 반환한다.

@PostMapping("/rinsert.bo")
@ResponseBody
public String insertReply(...) {
    int result = boardService.insertReply(reply);
    return result > 0 ? "1" : "0";
}

복잡한 데이터 구조를 전달할 때는 객체나 컬렉션을 반환하여 Jackson이 자동으로 JSON으로 변환하게 한다.

@GetMapping("/rlist.bo")
@ResponseBody
public List<Reply> selectReply(@RequestParam("boardNo") int boardNo) {
   return boardService.getReplyListByBoardNo(boardNo);
}

- 에러 처리

AJAX 요청 시 항상 error 콜백을 구현하여 네트워크 오류나 서버 오류에 대응해야 한다.

$.ajax({
    url: "rlist.bo",
    success: function(res){
        // 성공 처리
    },
    error: function(err){
        console.log("ajax 실패");
        alert("서버 오류가 발생했습니다.");
    }
})

<통신 흐름 분석>

- View Resolver 흐름

[클라이언트 요청]
        ↓
[DispatcherServlet]
        ↓
[Controller 메서드 실행]
        ↓
[뷰 이름 반환 (예: "board/listView")]
        ↓
[View Resolver 동작]
        ↓
[prefix + 뷰이름 + suffix 조합]
        ↓
[/WEB-INF/views/board/listView.jsp]
        ↓
[JSP 렌더링]
        ↓
[HTML 응답 생성]
        ↓
[클라이언트에 전체 페이지 전달]

- AJAX 비동기 통신 흐름

[클라이언트 AJAX 요청]
        ↓
[DispatcherServlet]
        ↓
[Controller 메서드 실행]
        ↓
[@ResponseBody 메서드]
        ↓
[객체/문자열 반환]
        ↓
[Jackson 라이브러리]
        ↓
[JSON 문자열로 변환]
        ↓
[HTTP 응답 본문에 작성]
        ↓
[클라이언트 JavaScript]
        ↓
[JSON 파싱]
        ↓
[DOM 조작으로 화면 일부 갱신]

첫 번째 흐름은 전체 페이지를 렌더링하는 전통적인 방식이며, 두 번째 흐름은 필요한 데이터만 주고받아 페이지의 일부를 갱신하는 현대적인 방식이다. 두 방식은 상황에 따라 적절히 선택하여 사용할 수 있다.


<핵심 개념 정리>

View Resolver는 Controller가 반환한 뷰 이름을 실제 JSP 파일 경로로 변환하는 역할을 수행한다. application.properties에서 prefix와 suffix를 설정하면 자동으로 경로가 조합되어 해당 JSP 파일을 찾아 렌더링한다. 이 방식은 전체 페이지를 렌더링할 때 적합하며, 구현이 간단하고 직관적이다.

@ResponseBody는 메서드의 반환값을 HTTP 응답 본문에 직접 작성하도록 지정하는 어노테이션이다. View Resolver를 거치지 않고 데이터를 바로 클라이언트에 전달하며, Spring Boot의 Jackson 라이브러리가 Java 객체를 자동으로 JSON 문자열로 변환한다. 이는 AJAX 비동기 통신의 핵심 메커니즘이다.

AJAX 비동기 통신은 페이지 전체를 새로고침하지 않고 필요한 데이터만 주고받아 화면의 일부를 동적으로 갱신한다. 콜백 함수 패턴을 활용하면 비동기 작업의 순서를 제어하고 코드의 재사용성을 높일 수 있다. 댓글 시스템과 같이 빈번한 업데이트가 필요한 기능에 특히 적합하다.

Jackson 라이브러리는 spring-boot-starter-web에 기본적으로 포함되어 있으며, 개발자가 별도의 설정 없이도 Java 객체와 JSON 간의 변환을 자동으로 처리한다. VO 클래스의 필드명이 JSON 객체의 키로 변환되며, getter/setter를 통해 값이 매핑된다.

동기 방식과 비동기 방식은 각각의 장단점이 있다. 동기 방식은 구현이 간단하지만 사용자 경험이 떨어지고 네트워크 자원이 낭비된다. 비동기 방식은 빠른 응답과 부드러운 사용자 경험을 제공하지만 JavaScript 코드의 복잡도가 증가한다. 프로젝트의 요구사항에 따라 적절한 방식을 선택하는 것이 중요하다.

Spring Boot는 전통적인 서버 사이드 렌더링과 현대적인 비동기 통신을 모두 지원하여, 페이지 전체 렌더링이 필요한 경우 View Resolver를 사용하고 부분 갱신이 필요한 경우 @ResponseBody와 AJAX를 사용하는 유연한 웹 애플리케이션 개발을 가능하게 한다.