💻 it/development

[spring/mybatis] Rest API 계층구조(1:N) 객체 조회(feat .쉬운 예제)

꼬비랩 2025. 12. 10.
 

역시나 미래의 나를 위해 기록한다.

계층구조 조회가 처음 접하고 익숙해지기 전까지는 좀 어렵다.(물론 지금도 쉬운 건 아니다.)

모든 일이 익숙해지면 아무것도 아닌 게 그전까지는 어려운 법이다.

Rest API 계층구조 조회 간단 예시다.

사용자(UserDto) 객체가 도서 객체(BookDto)를 list로 가지고 있는 구조다.

회원과 도서의 관계를 1:N으로 매핑했고 회원이 부모고 책이 자식이다.

아래처럼 한명의 회원이 여러개의 책을 대출할 수 있는 조건

그래서 회원 객체는 도서 객체를 list로 가지고 있어야 한다.

아래 DB 테이블을 보면 회원번호 1번인 천둥의 신 토르가 열혈강호 1권~5권까지 가지고 있다.

API니까 당연히 클라이언트에 전달할 객체인 dto와 db 조작시 사용할 객체인 vo를 따로 두었다.


DB 😄

회원 테이블

CREATE TABLE `temp_user` (
  `user_seq` int(20) NOT NULL AUTO_INCREMENT COMMENT '사용자번호',
  `user_name` varchar(30) NOT NULL COMMENT '사용자명',
  `user_email` varchar(30) NOT NULL COMMENT '사용자 이메일',
  `reg_date` timestamp NULL DEFAULT NULL COMMENT '등록일',
  PRIMARY KEY (`user_seq`,`user_email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci

테스트 데이터

INSERT INTO temp_user
(user_seq, user_name, user_email, reg_date)
VALUES(1, '토르', 'thor@naver.com', '2022-12-21 22:57:07.000');
INSERT INTO temp_user
(user_seq, user_name, user_email, reg_date)
VALUES(2, '헐크', 'hulk@naver.com', '2022-12-21 22:57:07.000');
INSERT INTO temp_user
(user_seq, user_name, user_email, reg_date)
VALUES(3, '타노스', 'tanos@naver.com', '2022-12-21 22:57:07.000');

도서 테이블

CREATE TABLE `temp_book` (
  `book_seq` int(20) NOT NULL AUTO_INCREMENT COMMENT '도서 번호',
  `book_name` varchar(30) NOT NULL COMMENT '도서명',
  `author` varchar(30) NOT NULL COMMENT '저자',
  `user_seq` int(11) NOT NULL COMMENT '사용자번호',
  PRIMARY KEY (`book_seq`,`book_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci

테스트데이터

INSERT INTO temp_book
(book_seq, book_name, author, user_seq)
VALUES(25, '열혈강호1권', '양극진', 1);
INSERT INTO temp_book
(book_seq, book_name, author, user_seq)
VALUES(26, '열혈강호2권', '양극진', 1);
INSERT INTO temp_book
(book_seq, book_name, author, user_seq)
VALUES(27, '열혈강호3권', '양극진', 1);
INSERT INTO temp_book
(book_seq, book_name, author, user_seq)
VALUES(28, '열혈강호4권', '양극진', 1);
INSERT INTO temp_book
(book_seq, book_name, author, user_seq)
VALUES(29, '열혈강호5권', '양극진', 1);
INSERT INTO temp_book
(book_seq, book_name, author, user_seq)
VALUES(30, '원피스1권', '일본1', 2);
INSERT INTO temp_book
(book_seq, book_name, author, user_seq)
VALUES(31, '원피스6권', '일본1', 2);
INSERT INTO temp_book
(book_seq, book_name, author, user_seq)
VALUES(32, '원피스7권', '일본1', 2);
INSERT INTO temp_book
(book_seq, book_name, author, user_seq)
VALUES(33, '슬램덩크1권', '일본2', 3);
INSERT INTO temp_book
(book_seq, book_name, author, user_seq)
VALUES(34, '슬램덩크100권', '일본2', 3);

DB 도서 테이블의 외래키는 테스트니 논리적으로만 설정했다.


Java단 😘

Vo(DB와 통신 용도)

네이밍 룰은 서버단이니 카멜케이스로 선언

아래 인텔리제이의 플러그인을 사용하면 좀 수월하다.

 

[IntelliJ] camelCase <-> snake_case 변환(feat. CamelCase plugin)

목차 camelCase snake_case 😊 개발시에 대체적으로 java에서는 카멜케이스(userName)를 사용하고 DB 필드는 스케이크케이스(user_name)을 사용하는데 수작업으로 하는 것보다 더 괜찮은 플러그인을 발견해

yaga.tistory.com

package study.lsyrestapitest1.domain.vo;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import java.util.ArrayList;
import java.util.List;

@Getter @Setter
@ToString
// 회원
public class UserVo {
    private int userSeq;
    private String userName;
    private String userEmail;
    private String regDate;
    private List<BookVo> bookVoList = new ArrayList<>();
}
package study.lsyrestapitest1.domain.vo;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter @Setter @ToString
// 도서
public class BookVo {
    private int bookSeq;
    private String bookName;
    private String author;
}

Dto(클라이언트와 통신 용도)

쉬운 예제를 위해 빌더패턴이 아닌 Getter/Setter를 이용했다.

네이밍 룰은 클라이언트단이니 스네이크케이스로 선언

package study.lsyrestapitest1.domain.dto;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

import java.util.ArrayList;
import java.util.List;

@Getter @Setter
@ToString
@NoArgsConstructor
// 회원
public class UserDto {
    private int user_seq;
    private String user_name;
    private String user_email;
    private String reg_date;
    private List<BookDto> book_dto_list = new ArrayList<>();

    public UserDto(int user_seq, String user_name, String user_email, String reg_date) {
        this.user_seq = user_seq;
        this.user_name = user_name;
        this.user_email = user_email;
        this.reg_date = reg_date;
    }
}
package study.lsyrestapitest1.domain.dto;

import lombok.*;

@Getter @Setter
@ToString
@NoArgsConstructor
// 도서
public class BookDto {
    private int book_seq;
    private String book_name;
    private String author;

    public BookDto(int book_seq, String book_name, String author) {
        this.book_seq = book_seq;
        this.book_name = book_name;
        this.author = author;
    }
}

mapper xml

resultMap의 collection을 이용해 계층구조를 매핑했고 resultMap으로 받았다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="study.lsyrestapitest1.mapper.UserMapper">
    <resultMap id="userMap" type="study.lsyrestapitest1.domain.vo.UserVo">
        <id column="user_seq" property="userSeq"/>
        <result column="user_name" property="userName"/>
        <result column="user_email" property="userEmail"/>
        <result column="reg_date" property="regDate"/>
        <!-- 책 list -->
        <collection property="bookVoList" javaType="java.util.ArrayList" resultMap="bookMap"/>
    </resultMap>

    <!--  책 -->
    <resultMap id="bookMap" type="study.lsyrestapitest1.domain.vo.BookVo">
        <id column="book_seq" property="bookSeq"/>
        <result column="book_name" property="bookName"/>
        <result column="author" property="author"/>
    </resultMap>

    <select id="findUserList" resultMap="userMap">
        SELECT  tu.user_seq
               ,tu.user_name
               ,tu.user_email
               ,tu.reg_date
               ,tb.book_seq
               ,tb.book_name
               ,tb.author
        FROM temp_user tu LEFT OUTER JOIN temp_book tb ON tu.user_seq = tb.user_seq
    </select>
</mapper>

혹은 아래처럼 collection의 ofType을 대상 클래스로 선언하고 resultMap안에다 넣어도 된다.

ofType을 선언하지 않을 경우 NPE가 발생한다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="study.lsyrestapitest1.mapper.UserMapper">
    <resultMap id="userMap" type="study.lsyrestapitest1.domain.vo.UserVo">
        <id column="user_seq" property="userSeq"/>
        <result column="user_name" property="userName"/>
        <result column="user_email" property="userEmail"/>
        <result column="reg_date" property="regDate"/>
        <!-- 책 list -->                                                                      
        <collection property="bookVoList" ofType="study.lsyrestapitest1.domain.vo.BookVo">
            <result column="book_seq" property="bookSeq"/>
            <result column="book_name" property="bookName"/>
            <result column="author" property="author"/>             
        </collection>
    </resultMap>

    <select id="findUserList" resultMap="userMap">
        SELECT  tu.user_seq
             ,tu.user_name
             ,tu.user_email
             ,tu.reg_date
             ,tb.book_seq
             ,tb.book_name
             ,tb.author
        FROM temp_user tu LEFT OUTER JOIN temp_book tb ON tu.user_seq = tb.user_seq
    </select>
</mapper>

※ hasOne(부모 자식의 관계가 1:1)관계는 association 태그를 이용하면 되고 아래처럼 사용하면 된다.

<association property="BookInfo" javaType="com.test.lsy.vo.bookInfo">
    <result column="book_no" property="BookNo"/>
    <result column="book_nm" property="BookNm"/>
</association>

mapper interface

package study.lsyrestapitest1.mapper;

import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;
import study.lsyrestapitest1.domain.vo.UserVo;

import java.util.List;

@Mapper @Repository
public interface UserMapper {

    List<UserVo> findUserList();
}

service

DB 조회 값을 Vo타입 list에 받아 list를 루프 돌며 새로 만든 dto객체에 세팅을 시킨 후 DtoList 반환

물론 stream 등을 이용해 더 코드를 간결화 할 수도 있지만 누구나 보기 쉽게 향상된 for문 이용

아니면 그냥 mapper에서부터 dto로 받으면 서비스 코드도 매우 간결해 진다.(이게 더 나을 듯 하다.)

package study.lsyrestapitest1.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import study.lsyrestapitest1.domain.dto.BookDto;
import study.lsyrestapitest1.domain.dto.UserDto;
import study.lsyrestapitest1.domain.vo.BookVo;
import study.lsyrestapitest1.domain.vo.UserVo;
import study.lsyrestapitest1.mapper.UserMapper;

import java.util.ArrayList;
import java.util.List;

@Service
@Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {

    private final UserMapper userMapper;
    
    public List<UserDto> findUserList() {
    	// DB값 조회한 값을 userVoList에 저장
        List<UserVo> userVoList = userMapper.findUserList();
        List<UserDto> userDtoList = new ArrayList<>();
		// userVoList를 루프 돌며 UserDto객체를 생성 해 Vo의 값을 dto에 세팅
        for (UserVo userVo : userVoList) {
            UserDto userDto = new UserDto(userVo.getUserSeq(), userVo.getUserName(), userVo.getUserEmail(), userVo.getRegDate());
			// UserVo의 bookVoList값을 bookVoList에 저장 후 루프 돌며 bookDtoList에 저장            
            List<BookVo> bookVoList = userVo.getBookVoList();
            List<BookDto> bookDtoList = new ArrayList<>();
            for (BookVo bookVo : bookVoList) {
                BookDto bookDto = new BookDto(bookVo.getBookSeq(), bookVo.getBookName(), bookVo.getAuthor());
                bookDtoList.add(bookDto);
                // userDto의 book_dto_list에 bookVoList의 값을 세팅
                userDto.setBook_dto_list(bookDtoList);
            }
            // userDtoList에 userDto 객체 추가
            userDtoList.add(userDto);
        }
        return userDtoList;
    }
}

controller

반환 data를 view 없이 Http Body에 실어 HttpStatus 200코드와 함께 반환한다.

@RestController는 @Controller와 @ResponseBody가 합쳐진 애노테이션이다.

package study.lsyrestapitest1.controller.api;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import study.lsyrestapitest1.domain.dto.UserDto;
import study.lsyrestapitest1.domain.vo.UserVo;
import study.lsyrestapitest1.service.UserService;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@RestController
@Slf4j
@RequiredArgsConstructor
@RequestMapping(value = "/api/v1/users")
public class UserApiController {

    private final UserService userService;

    @GetMapping(value = "")
    public ResponseEntity<?> findUserList() {
    
        Map<String, Object> resultMap = new HashMap<>();
        
        try {
            resultMap.put("resultData", userService.findUserList());
            resultMap.put("resultCode", "0000");
            resultMap.put("resultMsg", "정상적으로 처리되었습니다.");
            return new ResponseEntity(resultMap, HttpStatus.OK);
        } catch (Exception e) {
            log.info("exception :: {}", e.getMessage());
            resultMap.put("resultCode", "9999");
            resultMap.put("errorMsg", e.getMessage());
            resultMap.put("resultMsg", "내부 서비스 오류입니다.");
            return new ResponseEntity(resultMap, HttpStatus.BAD_REQUEST);
        }
    }
}

테스트

내가 원한 데이터 구조대로 잘 반환이 된다.

Map({})안에 list([])안에 uerDto객체({book_dto_list[]})가 담겨 있고 http 상태코드 200이 반환되었다.

처음에는 객체안에 객체타입 리스트가 있는 구조가 무척 헷갈렸는데 익숙해지니 좀 나아졌다. 

실무는 더 복잡한 구조가 많기에 이 간단한 구조부터 이해가 되어야 응용이 가능하다.

프로젝트  생성 후 패키지명만 변경해서 구현하면 결과값이 잘 나올 것이다.

이렇게 mybatis에서 계층 매핑하는 방법도 있고 쿼리로 데이터 한번에 가져와서 list에 담아 java단에서 for문 돌려서 매핑하는 방법이 있다.

댓글