MVC 코드 작성 가이드
SQL 쿼리
Section titled “SQL 쿼리”- sql 파일은
resources/mybatis/mapper디렉토리에 xml 형태로 위치한다. - sql 샘플
- select
SELECT #{userId} AS USER_ID, A.MENU_ID, IFNULL(B.BOOKMARK_YN, 'N') AS BOOKMARK_YNFROM MENU_MS ALEFT OUTER JOIN BOOKMARK_MS B ON A.MENU_ID = B.MENU_ID AND B.USER_ID = #{userId}WHERE A.MENU_ID IN<foreach collection="menuIds" item="item" open="(" close=")" separator=",">#{item}</foreach>
- insert
INSERT INTO MENU_MS ( MENU_ID, PARENT_ID, MENU_NAME, PATH, DEPTH, SORT_ORDER, USE_YN, CREATE_DT, CREATE_USER_ID, CREATE_IP, UPDATE_DT, UPDATE_USER_ID, UPDATE_IP)VALUES ( #{menuId}, #{parentId}, #{menuName}, #{path}, #{depth}, #{sortOrder}, 'Y', NOW(), #{createUserId}, #{createIp}, NOW(), #{updateUserId}, #{updateIp})
- update
UPDATE USER_MSSET PASSWORD = #{password}, UPDATE_DT = NOW(), UPDATE_USER_ID = #{updateUserId}, UPDATE_IP = #{updateIp}WHERE EMAIL = #{email}
- delete
DELETEFROM BOOKMARK_MSWHERE USER_ID = #{userId}AND MENU_ID = #{menuId}
- select…insert
INSERT INTO API_LOG_MSWITH SAME_API_PATH AS (SELECT COUNT(0) AS CNTFROM ( SELECT S.API_IDFROM API_MS SWHERE REGEXP_LIKE(#{path}, S.PATH_PATTERN)AND S.METHOD = #{method}AND S.USE_PATTERN_YN = 'Y'UNION ALLSELECT T.API_IDFROM API_MS TWHERE T.PATH = #{path}AND T.METHOD = #{method}AND T.USE_PATTERN_YN = 'N' ) Z)SELECT #{method}, #{path}, NOW(), #{userId}, #{createIp}, NOW(), #{userId}, #{updateIp}FROM SAME_API_PATHWHERE CNT = 0
- merge
INSERT INTO BOOKMARK_MS ( USER_ID, MENU_ID, BOOKMARK_YN, CREATE_DT, CREATE_USER_ID, CREATE_IP, UPDATE_DT, UPDATE_USER_ID, UPDATE_IP) VALUES ( #{userId}, #{menuId}, 'Y', NOW(), #{userId}, #{createIp}, NOW(), #{userId}, #{updateIp})ON DUPLICATE KEYUPDATE BOOKMARK_YN = 'Y', UPDATE_DT = NOW(), UPDATE_USER_ID = #{userId}, UPDATE_IP = #{updateIp}
- select
MVC 디렉토리 규칙
Section titled “MVC 디렉토리 규칙”Directoryapi.(MODULE).(UNIT_NAME)/
Directorycontroller/
- …
Directorydto/
Directorycontext/
- …
Directorycustom_model/
- …
Directorymapper/
- …
Directoryservice/
- …
- api 디렉토리 하부에 작성한다.
- MODULE: 모듈 이름
- API_NAME: API 이름
- 예제
api.common.code
DTO 클래스
Section titled “DTO 클래스”- DTO 클래스는 요청/응답 데이터를 감싸는 클래스이다.
- 요청 DTO 클래스
- RequestDto 접미사를 가진다.
- RequestBody, PathVariable 방식으로 파라미터를 전달할 수 있다.
- RequestBody 데이터 전달
- 데이터 전달시 payload 하위에 실 데이터를 전달한다.
{"payload": {"codeGroup": "FRUIT","code": "APPLE","page": {"page": 1,"pageSize": 20,"totalRows": 0}}}
- 데이터 전달시 payload 하위에 실 데이터를 전달한다.
- PathVariable 데이터 전달
http://localhost:8080/v1/common/code/1000
- RequestBody 데이터 전달
- 요청 DTO 클래스는 다른 메소드를 호출할때 값을 전달하는 용도로 사용하지 않는다.
- 값을 전달하는 용도로 Context, Model 과 같은 클래스를 사용한다.
- Context 클래스
- Context 클래스는 dto/context 디렉토리에 위치한다.
- 요청 DTO 클래스는 Context 인스턴스를 반환하는 용도로 toContext 메소드를 제공한다.
- Context 클래스는 작성된 API 에서만 사용할 수 있다. 재활용시 원본 클래스가 변경되었을때 사이드 이펙트가 발생하기 때문이다.
- Model 클래스
- Model 클래스는 model 디렉토리에 위치한다.
- 데이터베이스의 테이블에 매핑하는 클래스이다.
- 요청 DTO 클래스는 Model 클래스 인스턴스를 반환하는 용도로 toModel 메소드를 제공한다.
- 응답 DTO 클래스
- RequestDto 접미사를 가진다.
- ResponseBody 방식으로 값을 반환한다.
- 응답 데이터는 payload 하위에 위치하도록 한다.
- isSuccess, resultCode, message 는 오류 발생시 자동으로 설정된다.
{"payload": {"code": {"codeId": 1000,"codeGroup": "FRUIT","code": "APPLE","codeDesc": "사과","orderNo": 1,"useYn": "Y","createDt": "2025.12.31","createUserId": 1000,"createIp": "0:0:0:0:0:0:0:1","updateDt": "2025.12.31","updateUserId": 1000,"updateIp": "0:0:0:0:0:0:0:1"}},"isSuccess": true,"resultCode": "SUCCESS","message": null}
- 값을 반환하는 용도로 Model, Custom Model 클래스를 사용한다.
- Model 클래스
- Model 클래스는 model 디렉토리에 위치한다.
- 데이터베이스의 테이블에 매핑하는 클래스이다.
- Custom Model 클래스
- Custom Model 클래스는 dto/custom-model 디렉토리에 위치한다.
- 데이터베이스 조회 결과가 조인 등으로 확장되어 Model 클래스를 확장하는 경우에 사용한다.
- Custom Model 클래스는 작성된 API 에서만 사용할 수 있다. 재활용시 원본 클래스가 변경되었을때 사이드 이펙트가 발생하기 때문이다.
- 요청 DTO 클래스
- DTO 클래스 작성 규칙
- kotlin 버전에서는 하나의 API에 사용되는 RequestDto, ResponseDto 클래스가 별개의 파일로 분리되어 있다.
- java 인 경우에는 하나의 클래스에 내부 클래스 형태로 RequestDto와 ResponseDto 클래스가 배치되어 있다.
유닛테스트와코드 커버리지까지 작성했을 때, 별도의 파일로 분리하는게 유리하다는 결론을 도출하였다. 자바 버전은 향후 분리할 것이다.
- 예제
- RequestDto
SelectCodesRequestDto.kt @Schema(description = "코드 목록 조회 요청 DTO")data class SelectCodesRequestDto(@field:Schema(description = "코드 그룹")val codeGroup: String?,@field:Schema(description = "코드")val code: String?,@field:Schema(description = "코드 상세")val codeDesc: String?,@field:Schema(description = "페이지 정보")@field:NotNullval page: Page?,) {fun toContext(): SelectCodesContext =SelectCodesContext(codeGroup,code,codeDesc,page,)} - ResponseDto
SelectCodesResponseDto.kt @Schema(description = "코드 목록 조회 응답 DTO")data class SelectCodesResponseDto(@field:Schema(description = "코드 목록")val codes: List<Code>)
AdminMenuInsertMenuDto.java @Slf4jpublic abstract class AdminMenuInsertMenuDto {@Schema(description ="메뉴 생성 요청 DTO")@Getter@Setter@ToString@AllArgsConstructor@Builderpublic static class AdminMenuInsertMenuRequestDto {@Schema(description = "부모 ID")@NotNull(message = "TODO 코드반환 필요")private Integer parentId;@Schema(description = "메뉴 이름")@NotBlank(message = "TODO 코드반환 필요")private String menuName;@Schema(description = "경로")@NotBlank(message = "TODO 코드반환 필요")private String path;@Schema(description = "깊이")@NotNull(message = "TODO 코드반환 필요")private Integer depth;@Schema(description = "정렬 순서")@NotNull(message = "TODO 코드반환 필요")private Integer sortOrder;public Menu toMenu() {return Menu.builder().parentId(parentId).menuName(menuName).path(path).depth(depth).sortOrder(sortOrder).build();}}@Schema(description ="메뉴 생성 응답 DTO")@Getter@Setter@ToString@AllArgsConstructor@Builderpublic static class AdminMenuInsertMenuResponseDto {@Schema(description = "생성된 메뉴 정보")private Menu node;}} - RequestDto
매퍼 인터페이스
Section titled “매퍼 인터페이스”- 인터페이스 형태로 작성한다. 구현체 클래스는 Mybatis 라이브러리에서 자동으로 생성한다.
- SQL 파일과 1대 1로 매핑되도록 작성한다.
- 예제
CodeMapper.kt @Mapperinterface CodeMapper {fun selectCodes(param: SelectCodesContext): List<Code>fun selectCode(param: Int): Codefun insertCode(param: Code)fun updateCode(param: Code)fun deleteCode(param: Int)}AdminMenuMapper.java @Mapperpublic interface AdminMenuMapper {List<Tree> selectAllNodes(AdminMenuContext context);Menu selectMenu(int menuId);void insertMenu(Menu menu);void updateMenu(Menu menu);void updateMenuOrder(Menu menu);void deleteMenu(int menuId);List<Menu> selectAllMenus();List<Menu> selectSiblingNodes(AdminMenuContext context);}
서비스 클래스
Section titled “서비스 클래스”@Autowire 어노테이션대신 생성자를 통한 주입을 사용한다.- 인터페이스를 정의하지 않는다.
- 코드 전체를 try catch 구문으로 감싸서 예외가 발생할시 BizException 으로 감싸서 상위 클래스로 전파한다.
- 서비스 단위로 트랜잭션을 관리한다.
- 예제
CodeService.kt @Service@Transactional(rollbackFor = [BizException::class])class CodeService(private val codeMapper: CodeMapper) {fun selectCodes(param: SelectCodesContext): List<Code> {try {if (param.page !== null) {PageHelper.startPage<Code?>(param.page)return PageInfo.of(codeMapper.selectCodes(param)).list} else {return codeMapper.selectCodes(param)}} catch (e: Exception) {throw BizException(e)}}fun selectCode(param: Int): Code {try {return codeMapper.selectCode(param)} catch (e: Exception) {throw BizException(e)}}fun insertCode(param: Code) {try {return codeMapper.insertCode(param)} catch (e: Exception) {throw BizException(e)}}fun updateCode(param: Code) {try {return codeMapper.updateCode(param)} catch (e: Exception) {throw BizException(e)}}fun deleteCode(param: Int) {try {return codeMapper.deleteCode(param)} catch (e: Exception) {throw BizException(e)}}}AdminMenuService.java @Slf4j@Service@Transactional(rollbackFor = BizException.class)@RequiredArgsConstructorpublic class AdminMenuService {private final AdminMenuMapper adminMenuMapper;public List<Tree> selectAllNodes() {try {final User sessionUser = ServletUtil.getSessionUser();final AdminMenuContext context = AdminMenuContext.builder().userId(Objects.requireNonNull(sessionUser).getUserId()).build();return adminMenuMapper.selectAllNodes(context);} catch (Exception e) {throw new BizException(e);}}public Menu selectMenu(final int menuId) {try {return adminMenuMapper.selectMenu(menuId);} catch (Exception e) {throw new BizException(e);}}public Menu insertMenu(final Menu menu) {try {adminMenuMapper.insertMenu(menu);return menu;} catch (Exception e) {throw new BizException(e);}}public void updateMenu(final Menu menu) {try {adminMenuMapper.updateMenu(menu);} catch (Exception e) {throw new BizException(e);}}public void updateMenuOrder(final List<Menu> nodes) {try {nodes.forEach(adminMenuMapper::updateMenuOrder);} catch (Exception e) {throw new BizException(e);}}public void deleteMenu(final int menuId) {try {adminMenuMapper.deleteMenu(menuId);} catch (Exception e) {throw new BizException(e);}}public List<Menu> selectAllMenus() {try {return adminMenuMapper.selectAllMenus();} catch (Exception e) {throw new BizException(e);}}public List<Menu> selectSiblingNodes(final AdminMenuContext context) {try {return adminMenuMapper.selectSiblingNodes(context);} catch (Exception e) {throw new BizException(e);}}}
컨트롤러 클래스
Section titled “컨트롤러 클래스”@Autowire 어노테이션대신 생성자를 통한 주입을 사용한다.- 인터페이스를 정의하지 않는다.
- 예외 발생시 서비스에서 전달해준 BizException 기반으로 해서 오류 메시지를 반환한다.
- DTO 클래스에 Valid 설정이 되어 있는 경우 Valid 오류 메시지를 반환한다.
- 예제
@Tag(name = "Code", description = "코드 관리 API")@RestController@RequestMapping("/v1/common/code")@ResponseBodyclass CodeController(private val codeService: CodeService) {private val log = logger()@Operation(summary = "코드 목록 조회", description = "코드 목록 조회 API")@PostMapping("/list")fun selectCodes(@Valid @RequestBody param: RequestDto<SelectCodesRequestDto>): ResponseEntity<ResponseDto<SelectCodesResponseDto>> {log.info("selectCodes param: $param")val context = param.getPayload()?.toContext() as SelectCodesContextval payload = SelectCodesResponseDto(codeService.selectCodes(context))return ResponseEntity.ok(ResponseDto(payload))}@Operation(summary = "코드 조회", description = "코드 조회 API")@GetMapping("/{codeId}")fun selectCode(@PathVariable codeId: Int): ResponseEntity<ResponseDto<SelectCodeResponseDto>> {log.info("selectCode codeId: $codeId")val payload = SelectCodeResponseDto(codeService.selectCode(codeId))return ResponseEntity.ok(ResponseDto(payload))}@Operation(summary = "코드 등록", description = "코드 등록 API")@PostMappingfun insertCode(@Valid @RequestBody param: RequestDto<InsertCodeRequestDto>): ResponseEntity<ResponseDto<Unit>> {log.info("insertCode param: $param")val model = param.getPayload()?.toModel() as CodecodeService.insertCode(model)return ResponseEntity.status(HttpStatus.CREATED).body((ResponseDto(Unit)))}@Operation(summary = "코드 수정", description = "코드 수정 API")@PutMappingfun updateCode(@Valid @RequestBody param: RequestDto<UpdateCodeRequestDto>): ResponseEntity<ResponseDto<Unit>> {log.info("updateCode param: $param")val model = param.getPayload()?.toModel() as CodecodeService.updateCode(model)return ResponseEntity.ok((ResponseDto(Unit)))}@Operation(summary = "코드 삭제", description = "코드 삭제 API")@DeleteMapping("/{codeId}")fun deleteCode(@PathVariable codeId: Int): ResponseEntity<ResponseDto<Unit>> {log.info("deleteCode codeId: $codeId")codeService.deleteCode(codeId)return ResponseEntity.status(HttpStatus.NO_CONTENT).body(ResponseDto(Unit))}}
AdminMenuController.java @Tag(name = "메뉴 관리", description = "메뉴 관리 API")@Slf4j@RestController@RequiredArgsConstructor@RequestMapping("/v1/admin/menu")public class AdminMenuController {private final AdminMenuService adminMenuService;@Operation(summary = "전체 메뉴를 트리 구조로 조회", description = "전체 메뉴를 트리 구조로 조회하는 API")@GetMapping("/select-all-nodes")public @ResponseBody ResponseEntity<ResponseDto<AdminMenuSelectAllNodesResponseDto>> selectAllNodes() {return ResponseEntity.ok(new ResponseDto<>(AdminMenuSelectAllNodesResponseDto.builder().nodes(adminMenuService.selectAllNodes()).build()));}@Operation(summary = "메뉴 조회", description = "메뉴 조회 API")@GetMapping("/{menuId}")public @ResponseBody ResponseEntity<ResponseDto<AdminMenuSelectMenuNodeResponseDto>> selectMenu(@PathVariable int menuId) {return ResponseEntity.ok(new ResponseDto<>(AdminMenuSelectMenuNodeResponseDto.builder().node(adminMenuService.selectMenu(menuId)).build()));}@Operation(summary = "메뉴 생성", description = "메뉴 생성 API")@PostMappingpublic @ResponseBody ResponseEntity<ResponseDto<AdminMenuInsertMenuResponseDto>> insertMenu(@Valid @RequestBody final RequestDto<AdminMenuInsertMenuRequestDto> requestDto) {return ResponseEntity.status(HttpStatus.CREATED).body(new ResponseDto<>(AdminMenuInsertMenuResponseDto.builder().node(adminMenuService.insertMenu(requestDto.getPayload().toMenu())).build()));}@Operation(summary = "메뉴 수정", description = "메뉴 수정 API")@PutMappingpublic @ResponseBody ResponseEntity<ResponseDto<Void>> updateMenu(@Valid @RequestBody final RequestDto<AdminMenuUpdateMenuRequestDto> requestDto) {adminMenuService.updateMenu(requestDto.getPayload().toMenu());return ResponseEntity.ok(new ResponseDto<>());}@Operation(summary = "메뉴 순서 수정", description = "메뉴 순서 수정 API")@PutMapping("/update-menu-order")public @ResponseBody ResponseEntity<ResponseDto<Void>> updateMenuOrder(@Valid @RequestBody final RequestDto<AdminMenuUpdateMenuOrderRequestDto> requestDto) {adminMenuService.updateMenuOrder(requestDto.getPayload().getNodes());return ResponseEntity.ok(new ResponseDto<>());}@Operation(summary = "메뉴 삭제", description = "메뉴 삭제 API")@DeleteMapping("/{menuId}")public @ResponseBody ResponseEntity<ResponseDto<Void>> deleteMenu(@PathVariable int menuId) {adminMenuService.deleteMenu(menuId);return ResponseEntity.status(HttpStatus.NO_CONTENT).body(new ResponseDto<>());}@Operation(summary = "전체 메뉴를 조회", description = "전체 메뉴를 조회하는 API")@GetMappingpublic @ResponseBody ResponseEntity<ResponseDto<AdminMenuSelectAllMenusResponseDto>> selectAllMenus() {return ResponseEntity.ok(new ResponseDto<>(AdminMenuSelectAllMenusResponseDto.builder().nodes(adminMenuService.selectAllMenus()).build()));}@Operation(summary = "부모메뉴가 같은 자식메뉴를 조회", description = "부모메뉴가 같은 자식메뉴를 조회하는 API")@PostMapping("/select-sibling-nodes")public @ResponseBody ResponseEntity<ResponseDto<AdminMenuSelectSiblingNodesResponseDto>> selectSiblingNodes(@Valid @RequestBody final RequestDto<AdminMenuSelectSiblingNodesRequestDto> requestDto) {return ResponseEntity.ok(new ResponseDto<>(AdminMenuSelectSiblingNodesResponseDto.builder().nodes(adminMenuService.selectSiblingNodes(requestDto.getPayload().toContext())).build()));}}