MVC 코드 작성 가이드
dictionary-api의 CRUD 템플릿 기능인 board(게시판)를 예시로, Entity → Repository(JPA + QueryDsl) →
Service → Controller 전 계층의 실제 코드를 정리한다.
flowchart LR
client["클라이언트"] -- "RequestDto" --> ctrl["Controller"]
ctrl --> svc["Service"]
svc --> repo["Repository<br/>(JPA + QueryDsl)"]
repo --> db[("DB")]
repo -. "Entity" .-> svc
svc -. "Entity" .-> ctrl
ctrl -. "ResponseDto" .-> client
Entity
Section titled “Entity”- 감사 컬럼(등록/수정 일시·사용자·IP)은
AbstractEntity(@MappedSuperclass)가@PrePersist/@PreUpdate로 채운다. 세션이 없으면(비로그인/배치/테스트) 기본값(1000/::1)을 쓴다.entity/AbstractEntity.java @MappedSuperclass@Getter@Setterpublic abstract class AbstractEntity {private static final Long DEFAULT_USER_ID = 1000L;private static final String DEFAULT_IP = "::1";@Column(name = "create_dt")private LocalDateTime createDt;@Column(name = "create_user_id")private Long createUserId;@Column(name = "create_ip")private String createIp;@Column(name = "update_dt")private LocalDateTime updateDt;@Column(name = "update_user_id")private Long updateUserId;@Column(name = "update_ip")private String updateIp;@PrePersistprotected void onCreate() {final LocalDateTime now = LocalDateTime.now();final Long userId = resolveUserId();final String ip = resolveIp();createDt = now;createUserId = userId;createIp = ip;updateDt = now;updateUserId = userId;updateIp = ip;}@PreUpdateprotected void onUpdate() {updateDt = LocalDateTime.now();updateUserId = resolveUserId();updateIp = resolveIp();}private Long resolveUserId() {final SessionUser sessionUser = ServletUtils.getSessionUser();return Objects.nonNull(sessionUser) ? sessionUser.getUserId() : DEFAULT_USER_ID;}private String resolveIp() {final String ip = ServletUtils.getClientIp();return Objects.nonNull(ip) ? ip : DEFAULT_IP;}} - 새 엔티티는
AbstractEntity를 상속하고, 테이블의 감사 컬럼명이 다르면@AttributeOverride로 재정의한다.entity/BoardEntity.java @Entity@Table(name = "t_board")@Getter@Setter@Builder@ToString@NoArgsConstructor@AllArgsConstructorpublic class BoardEntity extends AbstractEntity {@Id@GeneratedValue(strategy = GenerationType.IDENTITY)@Column(name = "board_id")private Long id;@Column(name = "title", length = 200)private String title;@Column(name = "content", length = 1000, nullable = false)private String content;@Column(name = "photo_ph", length = 200, nullable = false)private String photoPath;}
Repository (JPA + QueryDsl)
Section titled “Repository (JPA + QueryDsl)”- Spring Data JPA 인터페이스가 QueryDsl 커스텀 인터페이스를 함께 상속한다.
repository/BoardRepository.java public interface BoardRepositoryextends JpaRepository<BoardEntity, Long>, BoardQueryDslRepository {} - QueryDsl 커스텀 인터페이스/구현체 — 동적 검색 조건과 페이징을 담당한다.
repository/BoardQueryDslRepository.java public interface BoardQueryDslRepository {List<BoardEntity> findAllOrderByCreateDtDesc(RequestDto<BoardFindAllRequestDto> request);Page<BoardEntity> findByPage(PaginationRequestDto<BoardFindByPageRequestDto> request);}repository/BoardQueryDslRepositoryImpl.java @RequiredArgsConstructorpublic class BoardQueryDslRepositoryImpl implements BoardQueryDslRepository {private final JPAQueryFactory queryFactory;@Overridepublic Page<BoardEntity> findByPage(final PaginationRequestDto<BoardFindByPageRequestDto> request) {final QBoardEntity board = QBoardEntity.boardEntity;final BooleanBuilder where = buildSearchCondition(board, request.getPayload());final Pageable pageable = request.getPagination().toPageable();final List<BoardEntity> content =queryFactory.selectFrom(board).where(where).orderBy(board.createDt.desc(), board.id.desc()).offset(pageable.getOffset()).limit(pageable.getPageSize()).fetch();final Long total =queryFactory.select(board.id.count()).from(board).where(where).fetchOne();return new PageImpl<>(content, pageable, total == null ? 0 : total);}private BooleanBuilder buildSearchCondition(final QBoardEntity board, final BoardFindByPageRequestDto condition) {final BooleanBuilder builder = new BooleanBuilder();if (condition == null) {return builder;}if (StringUtils.isNotBlank(condition.getTitle())) {builder.and(board.title.contains(condition.getTitle()));}return builder;}} - Q 클래스(
QBoardEntity)는 어노테이션 프로세서가 빌드 시점에 생성한다.Terminal window ./gradlew clean build
Service
Section titled “Service”@Service+@RequiredArgsConstructor. 리포지토리 하나만 생성자 주입 필드로 둔다. 모든 메소드가try/catch로 예외를 잡아BizException으로 감싼다.service/BoardService.java @Service@RequiredArgsConstructorpublic class BoardService {private final BoardRepository boardRepository;@Transactional(readOnly = true, rollbackFor = BizException.class)public BoardEntity find(final long id) {try {return boardRepository.findById(id).orElseThrow(() -> new BizException(CodeApiResult.NOT_FOUND));} catch (final Exception e) {throw new BizException(e);}}@Transactional(rollbackFor = BizException.class)public BoardEntity create(final RequestDto<BoardCreateRequestDto> request) {try {return boardRepository.save(request.getPayload().toEntity());} catch (final Exception e) {throw new BizException(e);}}@Transactional(rollbackFor = BizException.class)public BoardEntity update(final long id, final RequestDto<BoardUpdateRequestDto> request) {try {final BoardUpdateRequestDto payload = request.getPayload();final BoardEntity persist =boardRepository.findById(id).orElseThrow(() -> new BizException(CodeApiResult.NOT_FOUND));if (!Objects.equals(persist.getTitle(), payload.getTitle())) {persist.setTitle(payload.getTitle());}return boardRepository.save(persist);} catch (final Exception e) {throw new BizException(e);}}@Transactional(rollbackFor = BizException.class)public void delete(final long id) {try {if (!boardRepository.existsById(id)) {throw new BizException(CodeApiResult.NOT_FOUND);}boardRepository.deleteById(id);} catch (final Exception e) {throw new BizException(e);}}}create용 DTO는toEntity()로 새 엔티티를 만들지만,update용 DTO에는toEntity()가 없다.update는 영속 엔티티를 조회한 뒤 값이 다른 필드만setXxx()로 바꾸므로 새 엔티티가 필요 없다.find/delete처럼 조회 결과가 없으면BizException(CodeApiResult.NOT_FOUND)를 직접 던지고, 그 외 예외는 catch해서BizException(e)로 감싼다.
- 요청 DTO는 API(컨트롤러 메소드)마다 독립 파일/클래스를 가진다.
dto/BoardCreate.java public class BoardCreate {@Schema(description = "게시글 등록 요청 DTO")@Getter@Setter@ToString@AllArgsConstructor@NoArgsConstructorpublic static class BoardCreateRequestDto {@Schema(description = "제목", example = "금상")@NotBlankprivate String title;@Schema(description = "본문", example = "공모전")@NotBlankprivate String content;public BoardEntity toEntity() {return BoardEntity.builder().title(title).content(content).build();}}} - 응답 DTO는 기능 안의 API 5개(find/findAll/findByPage/create/update)가 함께 쓰는 클래스 하나다. 엔티티를
그대로 노출하지 않고 정적 팩토리(
from)로 변환한다. 감사 컬럼 중 사용자 ID·IP는 일부러 뺀다.dto/BoardResponseDto.java @Schema(description = "게시글 응답 DTO")@Getter@Setter@ToString@Builder@AllArgsConstructor@NoArgsConstructorpublic class BoardResponseDto {@Schema(description = "게시글 ID", example = "1000")private Long id;@Schema(description = "제목")private String title;@Schema(description = "등록일시")private LocalDateTime createDt;@Schema(description = "수정일시")private LocalDateTime updateDt;public static BoardResponseDto from(final BoardEntity entity) {if (entity == null) {return null;}return BoardResponseDto.builder().id(entity.getId()).title(entity.getTitle()).createDt(entity.getCreateDt()).updateDt(entity.getUpdateDt()).build();}}
Controller
Section titled “Controller”AbstractController를 상속하고@RestController,@RequestMapping("/v1/<feature>"),@RequiredArgsConstructor를 붙인다. 응답은 항상ok()/created()/noContent()헬퍼로 감싼다.controller/BoardController.java @Tag(name = "게시판(CRUD 템플릿)", description = "게시판(CRUD 템플릿) API")@RestController@RequestMapping("/v1/board")@RequiredArgsConstructorpublic class BoardController extends AbstractController {private final BoardService boardService;@Operation(summary = "게시판 데이터 조회", description = "게시판 데이터 조회 API")@GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)public @ResponseBody ResponseEntity<ResponseDto<BoardResponseDto>> find(@PathVariable final long id) {return ok(new ResponseDto<>(BoardResponseDto.from(boardService.find(id))));}@Operation(summary = "게시판 데이터 등록", description = "게시판 데이터 등록 API")@ApiResponse(responseCode = "201", description = "게시글 등록 성공(Location 헤더 포함)")@GuestForbidden@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE,produces = MediaType.APPLICATION_JSON_VALUE)public @ResponseBody ResponseEntity<ResponseDto<BoardResponseDto>> create(@Valid @RequestBody final RequestDto<BoardCreateRequestDto> request) {final BoardEntity result = boardService.create(request);return created(new ResponseDto<>(BoardResponseDto.from(result)),URI.create("/v1/board/" + result.getId()));}@Operation(summary = "게시판 데이터 삭제", description = "게시판 데이터 삭제 API")@ApiResponse(responseCode = "204", description = "게시글 삭제 성공")@GuestForbidden@DeleteMapping(value = "/{id}")public @ResponseBody ResponseEntity<Void> delete(@PathVariable final long id) {boardService.delete(id);return noContent();}}- 쓰기 API(
create/update/delete/deleteAll)에는@GuestForbidden을 붙여 게스트를 막는다.@PublicApi는 로그인이 필요 없는 API에만 붙인다. AbstractController가 제공하는 헬퍼common/api/AbstractController.java public abstract class AbstractController {public <T> ResponseEntity<T> ok(final T t) {return ResponseEntity.ok(t);}public <T> ResponseEntity<T> created(final T t, final URI location) {return ResponseEntity.created(location).body(t);}public ResponseEntity<Void> noContent() {return ResponseEntity.noContent().build();}}