Skip to content

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
  1. 감사 컬럼(등록/수정 일시·사용자·IP)은 AbstractEntity(@MappedSuperclass)가 @PrePersist/@PreUpdate로 채운다. 세션이 없으면(비로그인/배치/테스트) 기본값(1000/::1)을 쓴다.
    entity/AbstractEntity.java
    @MappedSuperclass
    @Getter
    @Setter
    public 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;
    @PrePersist
    protected 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;
    }
    @PreUpdate
    protected 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;
    }
    }
  2. 새 엔티티는 AbstractEntity를 상속하고, 테이블의 감사 컬럼명이 다르면 @AttributeOverride로 재정의한다.
    entity/BoardEntity.java
    @Entity
    @Table(name = "t_board")
    @Getter
    @Setter
    @Builder
    @ToString
    @NoArgsConstructor
    @AllArgsConstructor
    public 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;
    }
  1. Spring Data JPA 인터페이스가 QueryDsl 커스텀 인터페이스를 함께 상속한다.
    repository/BoardRepository.java
    public interface BoardRepository
    extends JpaRepository<BoardEntity, Long>, BoardQueryDslRepository {}
  2. QueryDsl 커스텀 인터페이스/구현체 — 동적 검색 조건과 페이징을 담당한다.
    repository/BoardQueryDslRepository.java
    public interface BoardQueryDslRepository {
    List<BoardEntity> findAllOrderByCreateDtDesc(RequestDto<BoardFindAllRequestDto> request);
    Page<BoardEntity> findByPage(PaginationRequestDto<BoardFindByPageRequestDto> request);
    }
    repository/BoardQueryDslRepositoryImpl.java
    @RequiredArgsConstructor
    public class BoardQueryDslRepositoryImpl implements BoardQueryDslRepository {
    private final JPAQueryFactory queryFactory;
    @Override
    public 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;
    }
    }
  3. Q 클래스(QBoardEntity)는 어노테이션 프로세서가 빌드 시점에 생성한다.
    Terminal window
    ./gradlew clean build
  1. @Service + @RequiredArgsConstructor. 리포지토리 하나만 생성자 주입 필드로 둔다. 모든 메소드가 try/catch로 예외를 잡아 BizException으로 감싼다.
    service/BoardService.java
    @Service
    @RequiredArgsConstructor
    public 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);
    }
    }
    }
  2. create용 DTO는 toEntity()로 새 엔티티를 만들지만, update용 DTO에는 toEntity()가 없다. update는 영속 엔티티를 조회한 뒤 값이 다른 필드만 setXxx()로 바꾸므로 새 엔티티가 필요 없다.
  3. find/delete처럼 조회 결과가 없으면 BizException(CodeApiResult.NOT_FOUND)를 직접 던지고, 그 외 예외는 catch해서 BizException(e)로 감싼다.
  1. 요청 DTO는 API(컨트롤러 메소드)마다 독립 파일/클래스를 가진다.
    dto/BoardCreate.java
    public class BoardCreate {
    @Schema(description = "게시글 등록 요청 DTO")
    @Getter
    @Setter
    @ToString
    @AllArgsConstructor
    @NoArgsConstructor
    public static class BoardCreateRequestDto {
    @Schema(description = "제목", example = "금상")
    @NotBlank
    private String title;
    @Schema(description = "본문", example = "공모전")
    @NotBlank
    private String content;
    public BoardEntity toEntity() {
    return BoardEntity.builder().title(title).content(content).build();
    }
    }
    }
  2. 응답 DTO는 기능 안의 API 5개(find/findAll/findByPage/create/update)가 함께 쓰는 클래스 하나다. 엔티티를 그대로 노출하지 않고 정적 팩토리(from)로 변환한다. 감사 컬럼 중 사용자 ID·IP는 일부러 뺀다.
    dto/BoardResponseDto.java
    @Schema(description = "게시글 응답 DTO")
    @Getter
    @Setter
    @ToString
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public 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();
    }
    }
  1. AbstractController를 상속하고 @RestController, @RequestMapping("/v1/<feature>"), @RequiredArgsConstructor를 붙인다. 응답은 항상 ok()/created()/noContent() 헬퍼로 감싼다.
    controller/BoardController.java
    @Tag(name = "게시판(CRUD 템플릿)", description = "게시판(CRUD 템플릿) API")
    @RestController
    @RequestMapping("/v1/board")
    @RequiredArgsConstructor
    public 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();
    }
    }
  2. 쓰기 API(create/update/delete/deleteAll)에는 @GuestForbidden을 붙여 게스트를 막는다. @PublicApi는 로그인이 필요 없는 API에만 붙인다.
  3. 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();
    }
    }