심화 4 @Transactional📚 전체 맵Security 개요
05e · 3차 심화과정 · STEP 5 / 5

@Builder 패턴

DTO 생성 방식을 Setter에서 Builder 패턴으로 전환 — 가독성 향상, 불변 객체 설계, toDto() 리팩토링

이 파일의 학습 목적
STEP 5 — @Builder, 왜 마지막에 배우나?

STEP 1~4를 거쳐 AOP·예외처리·@Transactional이 완성된 상태에서, 마지막으로 DTO 생성 방식을 개선한다. Setter로 한 줄씩 필드를 채우던 toDto() 메서드를 @Builder 체인으로 리팩토링하면, 가독성이 훨씬 좋아지고 중간에 미완성 객체가 노출되는 위험이 사라진다. 심화과정의 마지막 마무리 작업이다.

심화 5단계 — STEP 5로 심화과정 완료
STEP 1 ✅
AOP
로깅 자동화
STEP 2 ✅
컬렉션
이론 정리
STEP 3 ✅
예외처리
전역 핸들러
STEP 4 ✅
@Transactional
readOnly 최적화
📌 지금 여기
STEP 5
@Builder
3차까지의 문제
  • · toDto()가 new DTO() → setX() × 8줄 패턴 반복
  • · 중간에 미완성 객체가 메모리에 존재
  • · 필드 순서가 보장되지 않음 — 누락해도 컴파일 오류 없음
  • · @AllArgsConstructor만 있으면 필드 순서 실수 가능
STEP 5가 해결하는 것
  • · @Builder — .builder()...build() 체인으로 한 번에 생성
  • · @NoArgsConstructor + @AllArgsConstructor 함께 붙이는 이유
  • · .build() 호출 시점에만 객체 생성 → 미완성 노출 없음
  • · 필드명으로 직접 지정 → 순서 실수 방지
수정 파일: dto/BoardDto.java · dto/UserDto.java — @Builder 어노테이션 추가 / service 파일 — toDto() 리팩토링
Why Builder
왜 Builder 패턴을 쓰는가

지금까지 DTO를 만들 때 new BoardDto()로 빈 객체를 만들고, dto.setTitle(...), dto.setContent(...) ... 식으로 Setter를 여러 줄 호출했다. Builder 패턴은 이걸 하나의 체인으로 연결해서 한 번에 완성된 객체를 만든다. 가독성도 좋아지고, 완성되지 않은 객체가 중간에 쓰이는 실수를 방지할 수 있다.

❌ 기존 Setter 방식
toDto() — 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;
    // 문제: dto가 중간에 미완성 상태로
    // 존재하는 시간이 있음. 실수로
    // 중간 dto를 쓰면 null 필드가 있음
}
✅ Builder 패턴
toDto() — 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(); // 이 순간 완성!
    // .build() 전까지는 객체가 생성 안 됨
    // → 미완성 객체가 노출될 위험 없음
}
Concepts
@Builder 관련 Lombok 어노테이션
어노테이션역할없으면?
@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 같이 쓸 때 주의

@Builder는 내부적으로 전체 필드 생성자가 필요하다. 그런데 @NoArgsConstructor를 붙이면 기본 생성자만 있어서 컴파일 에러가 난다. 이때는 반드시 @AllArgsConstructor도 함께 붙여야 한다.

세 개를 같이 쓰는 패턴: @Builder @NoArgsConstructor @AllArgsConstructor

Builder 동작 원리 — 내부에서 무슨 일이 일어나나
@Builder가 생성하는 코드 (Lombok이 자동으로 만들어주는 것)
// @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 객체 생성
    }
}
💡 return this — 메서드 체이닝의 핵심

Builder의 각 메서드(id(), title() 등)가 return this로 Builder 자기 자신을 반환하기 때문에 .id(...).title(...).content(...)처럼 점(.)으로 계속 연결할 수 있다. 마지막에 .build()를 호출하면 비로소 실제 객체가 만들어진다. 이것이 Builder 패턴의 전부다.

Setter를 제거해도 되는 이유 — 불변성

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를 분리하는 것이 이상적이지만 현재 단계에서는 이 방식으로 진행.

Full Code
전체 코드 — @Builder 적용 완성본
dto/BoardDto.java — @Builder 추가
dto/BoardDto.java
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;
}
dto/UserDto.java — @Builder 추가
dto/UserDto.java
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;
}
service/BoardService.java — toDto() Builder 방식으로 변경
service/BoardService.java — toDto() 변경 부분만
// ─── 변경 전 (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();
}
service/AdminService.java — Builder 방식으로 변경
service/AdminService.java — getAllUsers(), getAllBoards() 변경
@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() 는 변경 없음
service/BoardService.java — STEP 3~5 반영 완전 완성본

STEP 3(CustomException), STEP 4(@Transactional), STEP 5(@Builder) 전부 반영된 최종 코드.

service/BoardService.java — 심화 최종 완성본
@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/AdminService.java — STEP 3~5 반영 완전 완성본
service/AdminService.java — 심화 최종 완성본
@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);
    }
}
Summary
심화과정 전체 변경 파일 최종 요약
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 커밋 순서 (권장)

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 패턴 적용"

심화 4 @Transactional📚 전체 맵Security 개요