DTO 생성 방식을 Setter에서 Builder 패턴으로 전환 — 가독성 향상, 불변 객체 설계, toDto() 리팩토링
STEP 1~4를 거쳐 AOP·예외처리·@Transactional이 완성된 상태에서, 마지막으로 DTO 생성 방식을 개선한다. Setter로 한 줄씩 필드를 채우던 toDto() 메서드를 @Builder 체인으로 리팩토링하면, 가독성이 훨씬 좋아지고 중간에 미완성 객체가 노출되는 위험이 사라진다. 심화과정의 마지막 마무리 작업이다.
@Builder — .builder()...build() 체인으로 한 번에 생성@NoArgsConstructor + @AllArgsConstructor 함께 붙이는 이유
지금까지 DTO를 만들 때 new BoardDto()로 빈 객체를 만들고,
dto.setTitle(...), dto.setContent(...) ... 식으로 Setter를 여러 줄 호출했다.
Builder 패턴은 이걸 하나의 체인으로 연결해서 한 번에 완성된 객체를 만든다.
가독성도 좋아지고, 완성되지 않은 객체가 중간에 쓰이는 실수를 방지할 수 있다.
private BoardDto toDto(Board board) { BoardDto dto = new BoardDto(); dto.setId(board.getId()); dto.setTitle(board.getTitle()); dto.setContent(board.getContent()); dto.setViewCount(board.getViewCount()); dto.setNickname(board.getUser().getNickname()); dto.setUsername(board.getUser().getUsername()); dto.setUserId(board.getUser().getId()); dto.setCreatedAt(board.getCreatedAt() .format(DateTimeFormatter .ofPattern("yyyy-MM-dd HH:mm"))); return dto; // 문제: dto가 중간에 미완성 상태로 // 존재하는 시간이 있음. 실수로 // 중간 dto를 쓰면 null 필드가 있음 }
private BoardDto toDto(Board board) { return BoardDto.builder() .id(board.getId()) .title(board.getTitle()) .content(board.getContent()) .viewCount(board.getViewCount()) .nickname(board.getUser().getNickname()) .username(board.getUser().getUsername()) .userId(board.getUser().getId()) .createdAt(board.getCreatedAt() .format(DateTimeFormatter .ofPattern("yyyy-MM-dd HH:mm"))) .build(); // 이 순간 완성! // .build() 전까지는 객체가 생성 안 됨 // → 미완성 객체가 노출될 위험 없음 }
| 어노테이션 | 역할 | 없으면? |
|---|---|---|
| @Builder | Builder 내부 클래스 + .builder() 정적 메서드 자동 생성 |
BoardDto.builder() 호출 불가 |
| @AllArgsConstructor | 모든 필드를 파라미터로 받는 생성자 자동 생성 | @Builder가 생성자를 찾지 못해 컴파일 에러 발생 |
| @NoArgsConstructor | 파라미터 없는 기본 생성자 자동 생성 | JPA가 Entity를 리플렉션으로 만들 때 필요. DTO는 선택사항이지만 Jackson(JSON 변환) 때문에 붙이는 게 안전 |
| @Getter | 모든 필드의 get메서드 자동 생성 | Jackson이 JSON 직렬화할 때 getter를 사용 — 없으면 JSON에 필드 안 나옴 |
| @Setter | 모든 필드의 set메서드 자동 생성 | Builder 방식에서는 Setter 불필요 → 이 STEP에서 Setter 제거 |
@Builder는 내부적으로 전체 필드 생성자가 필요하다.
그런데 @NoArgsConstructor를 붙이면 기본 생성자만 있어서 컴파일 에러가 난다.
이때는 반드시 @AllArgsConstructor도 함께 붙여야 한다.
세 개를 같이 쓰는 패턴: @Builder @NoArgsConstructor @AllArgsConstructor
// @Builder가 BoardDto에 자동으로 만들어주는 것 (직접 쓸 필요 없음, 이해용) public static BoardDtoBuilder builder() { return new BoardDtoBuilder(); } public static class BoardDtoBuilder { private Long id; private String title; private String content; // ... 모든 필드 public BoardDtoBuilder id(Long id) { this.id = id; return this; } public BoardDtoBuilder title(String title) { this.title = title; return this; } public BoardDtoBuilder content(String content) { this.content = content; return this; } // ... 각 필드마다 메서드 하나씩 (return this → 체이닝 가능) public BoardDto build() { return new BoardDto(this.id, this.title, this.content, ...); // ← .build() 호출 시점에 비로소 BoardDto 객체 생성 } }
Builder의 각 메서드(id(), title() 등)가 return this로 Builder 자기 자신을 반환하기 때문에
.id(...).title(...).content(...)처럼 점(.)으로 계속 연결할 수 있다.
마지막에 .build()를 호출하면 비로소 실제 객체가 만들어진다.
이것이 Builder 패턴의 전부다.
Builder로 생성 시점에 모든 필드를 채우면, 이후에 Setter로 값을 바꿀 필요가 없다.
Setter가 없으면 외부에서 실수로 DTO의 값을 바꾸는 일이 방지된다.
단, Controller에서 @RequestBody로 JSON을 받을 때 Jackson이 Setter를 사용하는 경우가 있으므로,
요청을 받는 DTO(예: 글쓰기 요청 DTO)는 Setter를 유지하거나 @JsonProperty를 사용하는 게 안전하다.
| DTO 용도 | Setter 필요 여부 | 이유 |
|---|---|---|
| 응답 DTO (toDto() 결과) | 제거 가능 | Builder로 만들고 나면 변경 불필요. @Getter만 있으면 JSON 직렬화 됨 |
| 요청 DTO (@RequestBody) | 유지 권장 | Jackson이 JSON → 객체 변환 시 기본 생성자 + Setter 방식 사용 |
BoardDto, UserDto는 요청/응답 둘 다 사용하는 혼합 DTO다.
가장 실용적인 방법은 @Builder @NoArgsConstructor @AllArgsConstructor @Getter @Setter 전부 붙이는 것.
Setter가 있어도 Builder를 사용하면 되고, 요청 수신도 정상 동작한다.
추후 요청/응답 DTO를 분리하는 것이 이상적이지만 현재 단계에서는 이 방식으로 진행.
package com.example.board.dto; import lombok.*; @Getter @Setter // @RequestBody 수신용으로 유지 @Builder // Builder 패턴 활성화 @NoArgsConstructor // 기본 생성자 (Jackson, JPA용) @AllArgsConstructor // 전체 필드 생성자 (@Builder 요구사항) public class BoardDto { private Long id; private String title; private String content; private String nickname; private String username; private int viewCount; private String createdAt; private Long userId; }
package com.example.board.dto; import lombok.*; @Getter @Setter // @RequestBody 수신용으로 유지 @Builder @NoArgsConstructor @AllArgsConstructor public class UserDto { private Long id; private String username; private String password; private String nickname; private String role; private String adminCode; }
// ─── 변경 전 (Setter 방식) ─────────────────────────────────────── private BoardDto toDto(Board board) { BoardDto dto = new BoardDto(); dto.setId(board.getId()); dto.setTitle(board.getTitle()); dto.setContent(board.getContent()); dto.setViewCount(board.getViewCount()); dto.setNickname(board.getUser().getNickname()); dto.setUsername(board.getUser().getUsername()); dto.setUserId(board.getUser().getId()); dto.setCreatedAt(board.getCreatedAt() .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))); return dto; } // ─── 변경 후 (Builder 방식) ────────────────────────────────────── private BoardDto toDto(Board board) { return BoardDto.builder() .id(board.getId()) .title(board.getTitle()) .content(board.getContent()) .viewCount(board.getViewCount()) .nickname(board.getUser().getNickname()) .username(board.getUser().getUsername()) .userId(board.getUser().getId()) .createdAt(board.getCreatedAt() .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))) .build(); }
@Transactional(readOnly = true) public List<UserDto> getAllUsers() { return userRepository.findAll().stream() .map(user -> UserDto.builder() // ← Builder 방식 .id(user.getId()) .username(user.getUsername()) .nickname(user.getNickname()) .role(user.getRole()) .build() ) .collect(Collectors.toList()); } @Transactional(readOnly = true) public List<BoardDto> getAllBoards() { return boardRepository.findAll().stream() .map(board -> BoardDto.builder() // ← Builder 방식 .id(board.getId()) .title(board.getTitle()) .nickname(board.getUser().getNickname()) .viewCount(board.getViewCount()) .build() ) .collect(Collectors.toList()); } // deleteUser(), deleteBoard() 는 변경 없음
STEP 3(CustomException), STEP 4(@Transactional), STEP 5(@Builder) 전부 반영된 최종 코드.
@Service @RequiredArgsConstructor public class BoardService { private final BoardRepository boardRepository; @Transactional(readOnly = true) public Page<BoardDto> getBoardList(int page) { Pageable pageable = PageRequest.of(page, 10, Sort.by("createdAt").descending()); Page<Board> boardPage = boardRepository.findAllByOrderByCreatedAtDesc(pageable); List<BoardDto> dtoList = boardPage.getContent().stream() .map(this::toDto).collect(Collectors.toList()); return new PageImpl<>(dtoList, pageable, boardPage.getTotalElements()); } @Transactional public BoardDto getDetail(Long id, User loginUser) { Board board = boardRepository.findById(id) .orElseThrow(() -> new CustomException("없는 게시글입니다.", 404)); if (loginUser == null || !board.getUser().getId().equals(loginUser.getId())) board.setViewCount(board.getViewCount() + 1); return toDto(board); } @Transactional public void write(BoardDto boardDto, User user) { Board board = new Board(); board.setTitle(boardDto.getTitle()); board.setContent(boardDto.getContent()); board.setUser(user); boardRepository.save(board); } @Transactional public void update(Long id, BoardDto boardDto, User user) { Board board = boardRepository.findById(id) .orElseThrow(() -> new CustomException("없는 게시글입니다.", 404)); if (!board.getUser().getId().equals(user.getId())) throw new CustomException("수정 권한이 없습니다.", 403); board.setTitle(boardDto.getTitle()); board.setContent(boardDto.getContent()); } @Transactional public void delete(Long id, User user) { Board board = boardRepository.findById(id) .orElseThrow(() -> new CustomException("없는 게시글입니다.", 404)); if (!board.getUser().getId().equals(user.getId())) throw new CustomException("삭제 권한이 없습니다.", 403); boardRepository.delete(board); } // STEP 5 — Builder 방식 toDto() private BoardDto toDto(Board board) { return BoardDto.builder() .id(board.getId()) .title(board.getTitle()) .content(board.getContent()) .viewCount(board.getViewCount()) .nickname(board.getUser().getNickname()) .username(board.getUser().getUsername()) .userId(board.getUser().getId()) .createdAt(board.getCreatedAt() .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))) .build(); } }
@Service @RequiredArgsConstructor public class AdminService { private final UserRepository userRepository; private final BoardRepository boardRepository; @Transactional(readOnly = true) public List<UserDto> getAllUsers() { return userRepository.findAll().stream() .map(user -> UserDto.builder() .id(user.getId()) .username(user.getUsername()) .nickname(user.getNickname()) .role(user.getRole()) .build() ) .collect(Collectors.toList()); } @Transactional(readOnly = true) public List<BoardDto> getAllBoards() { return boardRepository.findAll().stream() .map(board -> BoardDto.builder() .id(board.getId()) .title(board.getTitle()) .nickname(board.getUser().getNickname()) .viewCount(board.getViewCount()) .build() ) .collect(Collectors.toList()); } @Transactional public void deleteUser(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException("없는 유저입니다.", 404)); boardRepository.deleteByUser(user); userRepository.delete(user); } @Transactional public void deleteBoard(Long boardId) { Board board = boardRepository.findById(boardId) .orElseThrow(() -> new CustomException("없는 게시글입니다.", 404)); boardRepository.delete(board); } }
| STEP | 파일 | 변경 종류 | 핵심 변경 내용 |
|---|---|---|---|
| STEP 1 | build.gradle |
수정 | spring-boot-starter-aop 추가 |
| STEP 1 | aspect/LogAspect.java |
신규 | @Around로 Service 전체 메서드 로그 + 시간 측정 자동 적용 |
| STEP 3 | exception/CustomException.java |
신규 | RuntimeException 상속, httpStatus 필드 포함 |
| STEP 3 | exception/ErrorResponse.java |
신규 | 에러 응답 DTO — status, message, timestamp |
| STEP 3 | exception/GlobalExceptionHandler.java |
신규 | @RestControllerAdvice — CustomException + Exception 전역 처리 |
| STEP 3 | service/BoardService.java |
수정 | IllegalArgumentException → CustomException 교체 |
| STEP 3 | service/UserService.java |
수정 | IllegalArgumentException → CustomException 교체 |
| STEP 3 | service/AdminService.java |
수정 | IllegalArgumentException → CustomException 교체 |
| STEP 3 | controller/BoardController.java |
수정 | try/catch 전부 제거 |
| STEP 3 | controller/UserController.java |
수정 | try/catch 전부 제거 |
| STEP 3 | controller/AdminController.java |
수정 | try/catch 전부 제거 |
| STEP 4 | service/BoardService.java |
수정 | 조회 메서드에 @Transactional(readOnly=true) 추가 |
| STEP 4 | service/UserService.java |
수정 | login()에 @Transactional(readOnly=true) 추가 |
| STEP 4 | service/AdminService.java |
수정 | getAllUsers(), getAllBoards()에 @Transactional(readOnly=true) 추가 |
| STEP 5 | dto/BoardDto.java |
수정 | @Builder @NoArgsConstructor @AllArgsConstructor 추가 |
| STEP 5 | dto/UserDto.java |
수정 | @Builder @NoArgsConstructor @AllArgsConstructor 추가 |
| STEP 5 | service/BoardService.java |
수정 | toDto() Setter → Builder 방식으로 변경 |
| STEP 5 | service/AdminService.java |
수정 | getAllUsers(), getAllBoards() Setter → Builder 방식으로 변경 |
Entity, Repository, SecurityConfig, JwtUtil, JwtAuthenticationFilter, CustomUserDetails, CustomUserDetailsService, React 프론트 코드 — 심화과정에서 단 하나도 건드리지 않았다. LogAspect(신규), CustomException/ErrorResponse/GlobalExceptionHandler(신규), DTO 어노테이션 추가, @Transactional 추가, toDto() 리팩토링만 했다. 이것이 심화과정의 핵심: 기능 변경 없이 코드 품질 향상.
git checkout feature/security
git checkout -b feature/advanced
STEP 1 완료 후: git add . && git commit -m "feat: AOP 로깅 추가"
STEP 3 완료 후: git add . && git commit -m "feat: 전역 예외처리 적용"
STEP 4 완료 후: git add . && git commit -m "refactor: @Transactional readOnly 최적화"
STEP 5 완료 후: git add . && git commit -m "refactor: @Builder 패턴 적용"