이전 글에서 Repository를 만들었고, 이제 계층 간 데이터 교환을 위한 Dto를 만들 차례다.
Repository에 관한 자세한 내용은 이전 글을 참고하자.
[Java] 게시판 만들기 - 2
Board Entity를 만들었으니 Repository를 개발해보자. (이전글 참조) [Java] 게시판 만들기 - 1프로젝트 설계 부분은 이전 게시글을 참고하자. [Java] 게시판 만들기 - 0백엔드 공부용으로 CRUD 기능을 갖는
daniellee09.tistory.com
우선 아래처럼 클라이언트 요청의 종류에 따라 다른 data를 전송할 Dto 3개를 만들었다.

1. BoardResponseDto
클라이언트에게 보낼 게시글 응답 데이터와 관련된 Dto로 이해하면 될 것이다.
전체 코드는 다음과 같다.
package board.board_spring.dto;
import board.board_spring.entity.Board;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Getter @Setter
@AllArgsConstructor
public class BoardResponseDto {
private Long boardId;
private String title;
private String content;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// 정적 팩토리 메서드 추가
public static BoardResponseDto findFromBoard(Board board) {
return new BoardResponseDto(
board.getBoardId(),
board.getTitle(),
board.getContent(),
board.getCreatedAt(),
board.getUpdatedAt()
);
}
// 날짜 포맷팅 메서드
public String getFormattedCreatedAt() {
if (createdAt == null) return "";
return createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
}
public String getFormattedUpdatedAt() {
if (updatedAt == null) return "";
return updatedAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
}
// 상대적 시간 표시 메서드
public String getRelativeCreatedTime() {
if (createdAt == null) return "";
LocalDateTime now = LocalDateTime.now();
long minutes = java.time.Duration.between(createdAt, now).toMinutes();
if (minutes < 1) return "방금 전";
if (minutes < 60) return minutes + "분 전";
long hours = minutes / 60;
if (hours < 24) return hours + "시간 전";
long days = hours / 24;
if (days < 7) return days + "일 전";
return getFormattedCreatedAt();
}
}
기본 구조부터 주요 특징들을 한번 살펴보자.
1. 기본 구조: 필요한 데이터만 깔끔하게
가장 먼저 클라이언트(브라우저)의 게시글 조회 화면에 어떤 정보가 필요한지 살펴보았다.
@Getter @Setter
@AllArgsConstructor
public class BoardResponseDto {
private Long boardId;
private String title;
private String content;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// ... 추가 기능들
}
- boardId : 게시글의 고유 ID (수정/삭제 등에 사용)
- title : 제목
- content : 내용
- createdAt : 생성 시간
- updatedAt : 수정 시간
이 필드들을 기반으로 기본 뼈대를 만들고 Lombock의 @Getter, @Setter, @AllArgsConstructor를 활용해 보일러플레이트 코드를 제거했다.
2. 변환의 책임은 DTO에게: 정적 팩토리 메서드 도입
서비스(Service) 계층에서 Entity를 DTO로 변환할 때, 보통 아래와 같이 생성자를 직접 호출한다.
new BoardResponseDto(board.getBoardId(), board.getTitle(), ...);
하지만 이렇게 되면 서비스 계층이 DTO의 생성 방식에 너무 깊이 관여하게 된다.
DTO의 필드가 변경되면 서비스 코드까지 수정해야 하게 되는 상황이 발생한다.
이러한 문제를 해결하기 위해 정적 팩토리 메서드(Static Factory Method) 패턴을 도입했다.
// 정적 팩토리 메서드 추가
public static BoardResponseDto findFromBoard(Board board) {
return new BoardResponseDto(
board.getBoardId(),
board.getTitle(),
board.getContent(),
board.getCreatedAt(),
board.getUpdatedAt()
);
}
- 캡슐화: DTO를 생성하는 로직이 DTO 클래스 안으로 숨겨져 서비스 계층은 변환 과정을 신경 쓸 필요가 없어진다.
- 가독성 향상: 서비스 코드에서는 BoardResponseDto.findFromBoard(board) 처럼, 메서드 이름만으로도 "Board Entity로부터 DTO를 만든다"는 의도가 명확하게 드러난다.
- 일관성 유지: Entity를 DTO로 변환하는 모든 로직이 한곳에 모여있어, 변환 로직이 변경되어도 이 메서드만 수정하면 된다.
3. 프론트엔드 고려: 데이터 가공 메서드 추가
DB에서 가져온 LocalDateTime 객체는 2025-09-14T15:10:23.123456 과 같은 형태로 직렬화된다.
이 형식은 사용자에게 보여주기 적합하지 않다. 보통 프론트엔드(JavaScript)에서 이 데이터를 받아 파싱하고 포맷팅하는 작업을 거친다.
이런 부담을 그냥 백엔드에서 덜어주었다. DTO 내부에 데이터를 가공하는 메서드를 추가하여,
프론트엔드는 그냥 이 메서드의 결과만 보여주면 되도록 만들었다.
1) 날짜 포맷팅 (getFormattedCreatedAt)
가장 기본적인 yyyy-MM-dd HH:mm 형태로 날짜를 변환하는 메서드다.
// 날짜 포맷팅 메서드
public String getFormattedCreatedAt() {
if (createdAt == null) return "";
return createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
}
public String getFormattedUpdatedAt() {
if (updatedAt == null) return "";
return updatedAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
}
2) 상대적 시간 표시 (getRelativeCreatedTime)
최신 웹 서비스들처럼 "방금 전", "10분 전", "~시간 전" 과 같은 상대적 시간 표시 기능도 추가했다.
// 상대적 시간 표시 메서드
public String getRelativeCreatedTime() {
if (createdAt == null) return "";
LocalDateTime now = LocalDateTime.now();
long minutes = java.time.Duration.between(createdAt, now).toMinutes();
if (minutes < 1) return "방금 전";
if (minutes < 60) return minutes + "분 전";
long hours = minutes / 60;
if (hours < 24) return hours + "시간 전";
long days = hours / 24;
if (days < 7) return days + "일 전";
return getFormattedCreatedAt(); // 7일이 넘으면 기본 포맷으로 표시
}
이제 프론트엔드에서는 조건문 없이 board.relativeCreatedTime 값을 바로 화면에 표시하기만 하면 된다.
복잡한 시간 계산 로직이 서버에서 모두 처리되는 것이다.
이렇게 완성된 BoardResponseDto를 정리하면 다음과 같은 역할을 수행한다.
- API 응답 스펙을 명확히 정의
- Entity와 View 사이의 의존성을 완벽히 분리
- 데이터 변환 로직을 캡슐화하여 코드의 응집도를 높임
- 프론트엔드의 부담을 줄여주는 데이터 가공까지 처리
2. BoardPostDto
이번엔 게시글을 생성(Post)하는 요청을 처리하기 위한 DTO인 BoardPostDto 설계 과정이다.
클라이언트가 서버로 데이터를 보낼 때 가장 중요한 것은 유효성 검사이다.
잘못된 데이터가 서버로 넘어오는 것을 사전에 방지하는 것이 애플리케이션의 안전성을 높이는 첫걸음이다.
바로 BoardPostDto가 이 유효성 검사를 담당하는 역할을 한다.
package board.board_spring.dto;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class BoardPostDto {
@NotEmpty
private String title;
@NotEmpty
private String content;
}
1. 기본 구조: 필요한 요청 데이터 정의
게시글을 작성할 때 사용자로부터 어떤 정보를 받아야 하는가?
- title : 게시글 제목
- content : 게시글 내용
이 두 가지 정보가 필수적이다. 이를 기반으로 기본 틀을 잡고, 역시 Lombok의 @Getter, @Setter를 활용했다.
(이번에는 AllArgsConstructor가 필요 없는데, 그 이유는 뒤에서 설명하겠다.)
@Getter
@Setter
public class BoardPostDto {
// ... 유효성 검사 어노테이션
private String title;
private String content;
}
2. 입력값 검증: @NotEmpty로 안정성 확보
클라이언트로부터 제목과 내용이 비어있는 상태로 넘어오면 당연히 안 될 것이다.
게시글은 최소한의 내용이라도 포함해야 한다. 이처럼 데이터의 유효성을 검증하기 위해 jakarta.validation.constraints 패키지의 @NotEmpty 어노테이션을 사용했다.
import jakarta.validation.constraints.NotEmpty; // 꼭 'jakarta.validation.constraints' 임포트!
// ...
public class BoardPostDto {
@NotEmpty(message = "제목은 필수 입력 항목입니다.") // 비어있을 수 없음을 명시
private String title;
@NotEmpty(message = "내용은 필수 입력 항목입니다.")
private String content;
}
@NotEmpty 어노테이션의 역할은 다음과 같다.
- Null 값 검사: 필드가 null인지 확인한다.
- 빈 문자열 검사: 문자열의 경우, " "(공백 문자열)도 허용하지 않고, 빈 문자열("")인지 확인한다.
- 컬렉션/배열 검사: 컬렉션이나 배열의 경우, 요소가 하나도 없는지(isEmpty() == true) 확인한다.
만약 @NotEmpty 대신 @NotBlank를 사용하면 공백으로만 이루어진 문자열(" ")도 유효하지 않다고 판단할 수 있다.
게시글 제목이나 내용에는 단순히 공백으로만 이루어진 경우도 올바르지 않다고 볼 수 있으므로, 상황에 따라 @NotBlank를 고려해 볼 수도 있을 것이다.
3. Controller에서의 활용: @Validated와 함께
이렇게 설계된 BoardPostDto는 Controller에서 아래의 라이브러리를 통해 @Validated와 함께 사용된다.
import org.springframework.validation.annotation.Validated;
jakarta.validation.Valid와 기능적으로 매우 유사하지만, @Validated만의 추가 기능이 존재한다.
스프링 고유의 '유효성 검사 그룹' 기능을 추가로 제공하는 어노테이션이다.
UserDto라는 객체가 있다고 가정해 보자.
회원 가입(Create) 시: username, password, email이 모두 필수다.
회원 정보 수정(Update) 시: username, email만 필수이고, password는 사용자가 변경할 때만 입력하므로 필수가 아니다.
이때 @Validated를 사용하면 이 두 가지 상황을 하나의 DTO로 처리할 수 있습니다.
// 글 작성
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Long postBoard(@RequestBody @Validated BoardPostDto boardPostDto) {
return boardService.createBoard(boardPostDto); // 직접 반환
}
따라서 위처럼 사용될 수 있다.
@Validated는 BoardPostDto 내부에 선언된 @NotEmpty와 같은 제약 조건들을 자동으로 검증하도록 지시한다.
만약 유효성 검사에 실패하면 MethodArgumentNotVlaidException이 발생하여 개발자가 직접 if - else 문으로 모든 필드를 검사할 필요 없이, 깔끔하게 예외 처리를 할 수 있게 된다.
4. AllArgsConstructor를 사용하지 않은 이유
BoardResponseDto와 달리 BoardPostDto에는 @AllArgsConstructor를 붙이지 않았는데, 그 이유는 다음과 같다.
- 요청 DTO의 생성 방식: BoardPostDto는 주로 클라이언트로부터 JSON 형태로 데이터를 받아 Spring의 @RequestBody가 자동으로 객체를 매핑해준다. 이때 기본 생성자(Default Constructor)와 Setter가 사용된다.
- 불필요한 생성자: 만약 @AllArgsConstructor만 있다면, 기본 생성자가 없어져 @RequestBody가 데이터를 바인딩하지 못하는 문제가 발생할 수 있다. (물론 @NoArgsConstructor를 함께 사용하면 해결되지만, 여기서는 필요 없다고 판단)
따라서 BoardPostDto와 같은 요청 DTO는 @Getter, @Setter만으로 충분하며,
필요하다면 명시적으로 @NoArgsConstructor를 추가해주는 것이 일반적이다.
정리하면 BoardPostDto는 클라이언트로부터 들어오는 데이터를 안전하게 처리하고
비즈니스 로직이 시작되기 전에 유효성을 확보하는 중요한 역할을 한다.
- @NotEmpty를 활용한 필수 입력값 유효성 검사
- @Valid를 통한 Controller에서의 자동 검증
- 클린하고 안전한 데이터 처리 흐름 구축
3. BoardPatchDto
마지막으로 게시글 부분 수정(PATCH)과 관련된 BoardPatchDto에 대해 알아보자.
PUT이 리소스 전체를 교체하는 개념이라면, PATCH는 리소스의 일부만 변경하는 데 사용된다.
예를 들어, 사용자가 게시글의 '제목'만 수정하고 '내용'은 그대로 두고 싶을 수 있을 것이다.
이런 PATCH의 특성을 담은 DTO에 대해 살펴보자.
package board.board_spring.dto;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class BoardPatchDto {
private String title;
private String content;
// null 및 빈 문자열 체크 메서드
// 제목용
public boolean hasTitle() {
return title != null && !title.trim().isEmpty();
}
// 내용
public boolean hasContent() {
return content != null & !content.trim().isEmpty();
}
}
1. 딜레마: 수정하지 않은 부분은 어떻게 처리할 것인가?
PATCH 요청을 처리할 때 고민은 "클라이언트가 보내지 않은 필드는 어떻게 처리해야 하는가?" 이다.
예를 들어, 클라이언트가 제목만 수정하기 위해 아래와 같은 JSON을 보냈다고 가정해보자.
{
"title": "수정된 새로운 제목입니다."
}
이 요청을 받은 BoardPatchDto는 title 필드에는 값이 채워지지만, content 필드는 null이 된다.
만약 서비스 로직에서 아무런 처리 없이 content 필드까지 DB에 업데이트해 버린다면, 기존에 있던 멀쩡한 내용이 null로 변경되는 대참사가 발생한다.
2. 기본 구조 및 잘못된 접근
우선 DTO의 기본 구조는 title과 content 필드를 가진다.
@Getter @Setter
public class BoardPatchDto {
private String title;
private String content;
// ...
}
여기서 흔히 하는 실수가 BoardPostDto 처럼 @NotEmpty나 @NotBlank 같은 유효성 검사 어노테이션을 붙이는 것이다.
// 잘못된 예시
public class BoardPatchDto {
@NotEmpty // 이렇게 하면 제목만 수정하고 싶을 때, 내용이 없다는 에러 발생!
private String title;
@NotEmpty
private String content;
}
만약 위와 같이 설계하면 제목만 수정하는 요청은 content가 비어있기 때문에 유효성 검사에서 항상 실패하게 된다.
따라서 PATCH용 DTO에서는 필드 레벨의 유효성 검사 어노테이션을 사용하지 않아야 한다.
3. 해결책: 헬퍼 메서드
이런 문제를 해결하기 위해 DTO 내부에 "클라이언트가 이 필드 값을 정말 보냈는가?"를 알려주는 헬퍼(Helper) 메서드를 추가했다.
public class BoardPatchDto {
private String title;
private String content;
// null 및 빈 문자열 체크 메서드
// 제목용
public boolean hasTitle() {
return title != null && !title.trim().isEmpty();
}
// 내용용
public boolean hasContent() {
return content != null && !content.trim().isEmpty();
}
}
- hasTitle() / hasContent() : 이 메서드들은 필드가 null이 아니고, 공백 문자를 제외한 실제 내용이 있는지를 검사하여 true 또는 false를 반환한다.
4. 서비스 계층에서의 활용
서비스는 트랜잭션을 관리하고, 올바른 엔티티를 찾아온 뒤, 실제 업데이트 로직은 엔티티에게 "알아서 이 DTO로 업데이트 해" 라고 위임한다.
서비스가 필드 하나하나를 setTitle, setContetn 등으로 직접 제어하지 않기 때문에 코드가 매우 깔끔하고 간결해진다.
// Service
public BoardResponseDto updateBoard(Long boardId, BoardPatchDto boardPatchDto) {
Board board = findBoardId(boardId); // 1. 영속성 컨텍스트에 엔티티 로드
board.updateFrom(boardPatchDto); // 2. 엔티티에 업데이트 위임
// 3. Transactional 종료 시 변경 감지(Dirty Checking)로 자동 UPDATE 쿼리 실행
return BoardResponseDto.findFromBoard(board);
}
미리 서비스 코드를 엿보자면 위와 같이 이루어져 있다.
자세한 부분은 다음 글에서 설명하겠다.
정리하면 BoardPatchDto의 핵심은 다음과 같다.
- 필드 레벨의 유효성 검사 어노테이션(@NotEmpty 등)을 사용하지 않는다.
- 헬퍼 메서드(hasTitle, hasContent)를 제공하여 서비스 계층에서 값이 실제로 존재하는지 쉽게 확인할 수 있도록 한다.
- 서비스 로직의 안정성과 명확성을 크게 향상시킨다.
4. 마무리
이제 게시판의 핵심 데이터 객체인 DTO까지 개발이 마무리되었다.
조회(Read), 생성(Create), 수정(Update)이라는 각기 다른 흐름 속에서 DTO가 어떻게 자신만의 역할을 수행하는지 함께 살펴보았다.
- BoardResponseDto 는 Entity를 안전하게 감싸고, FE가 사용하기 편한 형태로 데이터를 가공해준다.
- BoardPostDto 는 @Validated를 통해 서버의 문을 지켜주는 검사관 역할을 맡았다.
- BoardPatchDto 는 부분 수정이라는 까다로운 요구사항을 유연하게 처리하는 역할을 수행한다.
이 과정을 통해 DTO는 API의 의도를 명확히 드러내는 설계 도구이며, 각 계층의 책임을 분리하여 코드를 더 깔끔하고 견고하게 만드는 핵심 아키텍처 패턴임을 재확인했다.
이어서 비즈니스 로직이 담긴 Servcie 계층을 본격적으로 만들어보자.
'개발 > java' 카테고리의 다른 글
| [Java] JPA란? (1) | 2026.03.20 |
|---|---|
| [Java] 게시판 만들기 - 2 (0) | 2025.09.08 |
| [Java] 게시판 만들기 - 1 (0) | 2025.09.07 |
| [Java] 게시판 만들기 - 0 (0) | 2025.09.07 |
| [Java] main method 분석 (2) | 2025.08.08 |