최원종의 개발 블로그

V5-2 서비스 계층 만들어 보기 (BoardService, UserService) + 리팩토링 본문

Spring boot 입문

V5-2 서비스 계층 만들어 보기 (BoardService, UserService) + 리팩토링

chl6698 2026. 5. 20. 13:56

Service 계층

Service 계층은 Spring MVC 아키텍처에서 비즈니스 로직을 담당하는 계층이다.
Controller와 Repository 사이에 위치하여 실제 업무 처리를 담당함

BoardService 간단 요약

클래스 레벨 @Transactional(readOnly = true) 도입 : 전체 메서드를 읽기 전용으로 설정하여
JPA 조회 성능 최적화 및 안정성 확보

쓰기 작업 메서드에 @Transactional 분리 : save, updateById, deleteById에만
애노테이션을 따로 붙여 쓰기 권한 부여

Fetch Join 리포지토리 메서드 적용 : findAll()과 findById()에서 FETCH JOIN 쿼리를 호출하여
N+1 성능 문제 완벽 방어

람다 표현식 기반 예외 처리 : Optional의 orElseThrow() 내부에 람다식(() ->)을 적용해 
가독성 높은 404 예외 처리 구현

캡슐화 기반 인가(권한) 체크 도입 : boardEntity.isOwner()를 통해 게시글 작성자와 로그인 사용자가
같은지 검증하는 방어 코드 추가

더티 체킹을 통한 데이터 수정 : updateById에서 별도의 update 쿼리 없이 
객체의 값만 변경(boardEntity.update)하여 수정 완료

 

BoardService 핵심 리팩토링 및 변경 포인트 요약 

1. 트랜잭션 전략의 이원화 (성능 최적화)
기존: 메서드마다 트랜잭션을 생각 없이 걸거나 빠뜨릴 수 있었음.

변경: 클래스 상단에 @Transactional(readOnly = true)를 기본으로 깔아두어
findAll, findById 같은 조회 메서드들의 성능을 극대화함. (JPA가 스냅샷을 찍지 않아 메모리가 절약됨.)

반면, 데이터 변경이 일어나는 save, updateById, deleteById 메서드 위에만
따로 @Transactional을 붙여서 쓰기 권한을 명확히 분리.

2. 엔티티 객체에 책임을 위임한 인가(Authorization) 처리
Board boardEntity = findById(id);
boardEntity.isOwner(sessionUser.getId()); // 객체지향적 권한 체크

의미: 게시글 수정이나 삭제 시, "이 글을 지울 권한이 있는 사람인가?"를 검증하는 단계.

리팩토링 포인트: 서비스 레이어에서 if문으로 ID를 일일이 비교하는 대신,
Board 엔티티 객체 내부에 isOwner() 메서드를 만들어서 검증을 위임함.
객체지향 설계의 '단일 책임 원칙(SRP)'을 적용한 코드.

3. 지난번에 학습한 내용들의 완벽한 조화
Optional + 람다식: findById에서 글이 없을 때 
예외를 던지는 코드가 orElseThrow(() -> new Exception404(...)) 형태로 깔끔하게 구현함.

더티 체킹: updateById 메서드에서 리포지토리의 save나 수정 메서드를 호출하지 않고,
영속화된 엔티티의 데이터만 바꿈으로써(boardEntity.update(updateDTO)) 수정함.

N+1 방어: 이전에 BoardRepository에 직접 만들었던 findAllJoinUser()와 findByIdJoinUser()를
서비스에서 호출하여 작성자 정보까지 한방 쿼리로 긁어오도록 연결함.

BoardService 코드 ⬇️

더보기
package com.tenco.blog.board;

import com.tenco.blog._core.errors.Exception404;
import com.tenco.blog.user.User;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

// 객체지향 개념 - 단일 책임에 원칙

@Slf4j
// 서비스 계층은 @Service 어노테이션으로 IoC 처리
@Service // IoC 처리
@RequiredArgsConstructor // DI 처리
@Transactional(readOnly = true)
// 모든 메서드를 읽기 전용 트랜잭션으로 실행(findAll, findById 등 조회에 적합)
// 성능 최적화(변경 감지 비활성화 됨), 즉 조회시 데이터 수정 방지
public class BoardService {
    // @Autowired
    private final BoardRepository boardRepository;

    // 게시글 저장
    // 데이터 수정이 필요하므로 깊은 트랜잭션 처리
    // (읽기 전용 트랜잭선을 해제, 쓰지 전용 트랜잭션으로 변경)
    @Transactional
    public Board save(BoardRequest.SaveDTO saveDTO, User sessionUser) {
        // 1. 로그 기록 - 게시글 저장 요청 정보
        // 2. DTO를 Entity로 변환(작성자 정보 포함)
        // 3. 데이터베이스에 게시글 저장
        // 4. 저장 완료 로그 기록
        // 5. 저장된 게시글을 컨트롤러 단으로 반환
        log.info("게시글 저장 서비스 시작 - 제목 : {}, 작성자 : {}",
                saveDTO.getTitle(), sessionUser.getUsername());
        Board board = saveDTO.toEntity(sessionUser);

        Board savedBoardEntity = boardRepository.save(board);
        log.info("게시글 저장 완료 - ID : {}, 제목 : {}",
                savedBoardEntity.getId(), savedBoardEntity.getTitle());
        return savedBoardEntity;
    }

    // 게시글 목록 조회
    public List<Board> findAll() {
        // 1. 로그 기록 - 게시글 목록 조회
        // 2. 데이터베이스 접근해서 모든 게시글 목록을 조회
        // 3. 로그 기록 - (총 개시글 수)
        // 4. 조회된 게시글 목록을 컨트롤러로 반환
        log.info("게시글 목록 조회 서비스");
        List<Board> boardList = boardRepository.findAllJoinUser();
        log.info("게시글 목록 조회 완료 - 총 : {}", boardList.size());
        return boardList;
    }

    // 게시글 상세 보기
    public Board findById(Integer id) {
        // 1. 로그 기록 - 게시글 상세 조회 (id)
        // 2. 데이터베이스 접근해서 해당 ID의 게시글 조회 (작성자 정보 포함)
        // 3. 게시글이 존재하지 않으면 Exception404로 예외 발생
        // 4. 조회 성공시 로그 기록 (제목, 작성자 정보)
        // 5. 조회된 게시글 컨트롤러 단으로 반환
        log.info("게시글 상세 조회 서비스");
        Board boardEntity = boardRepository.findByIdJoinUser(id).orElseThrow(() -> {
            log.warn("게시글 조회 실패 - ID: {}", id);
            return new Exception404("해당하는 게시글을 찾을 수 없습니다");
        }) ;

        log.info("게시글 조회 완료 - 제목: {}, 작성자: {}",
                boardEntity.getTitle(), boardEntity.getUser().getUsername());
        return boardEntity;
    }

    // 게시글 수정
    @Transactional
    public Board updateById(Integer id, BoardRequest.UpdateDTO updateDTO, User sessionUser) {
        // 1. 로그 기록 - 게시글 수정 요청 정보 (board pk, 새 제목, 요청자)
        // 2. 수정하고자 하는 게시글 조회 (중간 삭제 되는 경우도 있음)
        // 3. 권한 확인 (인가 처리)
        // 4. 권한이 없다면 Exception403 예외 발생
        // 5. 더티 체킹으로 게시글 수정 (JPA 영속성 컨텍스트 활용)
        // 6. 수정 완료 로그 기록
        // 7. 수정된 게시글 반환

        log.info("게시글 수정 서비스");
        Board boardEntity = findById(id);
        boardEntity.isOwner(sessionUser.getId());

        // 영속화 되어 있었던 객체의 title, content 의 내용이 변경 됨.
        boardEntity.update(updateDTO);

        log.info("게시글 수정 완료 - ID : {}, 새 제목: {}",
                boardEntity.getId(), boardEntity.getTitle());
        return boardEntity;
    }

    // 게시글 삭제 (권한 체크 포함)
    @Transactional
    public void deleteById(Integer id, User sessionUser) {
        // 1. 로그 기록 - 게시글 삭제 요청 정보 (board PK, 요청자)
        // 2. 삭제하려는 게시글 조회
        // 3. 권한 확인 - 게시글 작성자와 요청자가 동일한지 확인
        // 4. 권한이 없다면 Exception403 예외 발생
        // 5. 데이터베이스에서 게시글 삭제 실행
        // 6. 삭제 완료 로그 기록

        log.info("게시글 삭제 서비스");
        Board boardEntity = findById(id);
        boardEntity.isOwner(sessionUser.getId());
        boardRepository.deleteById(id);
        log.info("게시글 삭제 완료 - ID : {}", id);
    }


    /**
     * 게시글 수정 화면 요청(인가 처리)
     * @param id (Board PK)
     * @param sessionUser (로그인한 사용자 정보)
     * @return Board
     */
    public Board findByIdAndCheckOwner(Integer id, User sessionUser) {
        log.info("게시글 수정 화면 조회 서비스");
        Board boardEntity = findById(id);
        boardEntity.isOwner(sessionUser.getId());
        log.info("게시글 수정 조회 완료 - 제목: {}, 작성자: {}",
                boardEntity.getTitle(), boardEntity.getUser().getUsername());
        return boardEntity;
    }
}

 


Board 코드 요약

@Entity 및 @Table 설정 : Board 클래스를 데이터베이스의 board_tb 테이블과 1:1로 매핑하여 
JPA 관리 대상으로 등록

기본키(PK) 전략 변경 : GenerationType.IDENTITY를 사용하여
ID 생성을 MySQL 등 DB의 AUTO_INCREMENT 기능에 위임

연관관계 매핑 (ManyToOne) : 기존의 평문 username 필드를 삭제하고, 
User 엔티티와 다대일(@ManyToOne) 외래키(user_id) 관계로 연결

지연 로딩(FetchType.LAZY) 선택 : Board 조회 시 무조건 User를 조인하지 않고, 
실제 필요할 때만 추가 조회하여 성능 최적화 기반 마련

@CreationTimestamp 자동화 : 하이버네이트 기능을 활용해 글이 저장되는 시점의 서버 시간을
createdAt 컬럼에 자동 주입

객체지향적 편의 메서드 구현 : 엔티티 내부에서 스스로 데이터를 수정(update)하고 
소유권을 검증(isOwner)하는 방어 로직 완성

 


Board 핵심 매핑 및 변경 포인트 상세 분석

1. 관계형 DB 구조를 반영한 객체 지향적 연관관계 매핑
// private String username; <- 삭제
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;

리팩토링 포인트: 기존 방식처럼 테이블에 단순히 작성자 이름(username) 문자열만 박아두면,
작성자의 이메일이나 프로필 사진을 가져오기 위해 또 우회해서 쿼리를 날려야 했음.

개선 효과: 문자열 필드를 지우고 User 엔티티 통째로 관계를 맺어줌으로써, 
객체 세상에서는 board.getUser().getEmail()처럼 자유롭게 
작성자의 모든 정보에 패스(Graph Navigate)할 수 있게 됨.

2. 성능 최적화의 대원칙: FetchType.LAZY (지연 로딩)
EAGER (즉시 로딩): 게시글 1건만 조회해도 무조건 작성자 정보까지 JOIN해서 가져옴.
당장 작성자 이름이 필요 없는 대량의 통계 조회 등에서도 무조건 조인이 걸려 성능 저하의 주범이 됨.

LAZY (지연 로딩): boardJPARepository.findById()를 할 때는 딱 board_tb만 조회.
연관된 User는 나중에 board.getUser().getUsername()처럼 실제로 닉네임을 꺼내 쓰려고 할 때
카카오톡 알림 오듯 DB에 쿼리를 싹 날려 채워 넣음.

팁: 실무에서는 무조건 LAZY를 기본으로 깔고,
한방 쿼리가 필요할 때만 앞서 배운 JOIN FETCH를 명시하는 것이 정석.

3. 단일 책임 원칙(SRP)을 지킨 객체지향 편의 메서드

update(updateDTO): 엔티티 본인이 직접 상태를 변경하게 함으로써 
데이터 오염을 막고 안전하게 더티 체킹을 수행.

isOwner(sessionUserId): 서비스 레이어에서 하던 권한 체크(인가) 책임을 데이터 주인인 Board 엔티티에게 넘김. 
조건에 맞지 않으면 본인이 직접 Exception403을 터트림

Board 코드 ⬇️

더보기
package com.tenco.blog.board;

import com.tenco.blog._core.errors.Exception403;
import com.tenco.blog.user.User;
import com.tenco.blog.util.MyDateUtil;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;

import java.sql.Timestamp;

@Data // get,set, toString ..
// @Entity - JPA가 이 클래스를 데이터베이스 테이블과 매핑하는 객체로 인식하게 설정
// 즉, 이 어노테이션이 있어야 JPA가 관리 함
@Entity
@Table(name = "board_tb")
@NoArgsConstructor
@AllArgsConstructor // 전체 멤벼 번수를 넣을 수 있는 생성자.
@Builder
public class Board {

    // @id : 이 필드가 기본키임을 설정 함
    @Id
    // IDENTITY 전략: 데이터베이스게 기본 AUTO_INCREMENT 기능 사용
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
//    private String username;  삭제해야 함.
    private String title;
    private String content;

    // 연관관계 설정 해주어야 한다.
    // 다대일 연관관계 : 여러개 게시글이 하나의 사용자에게 속한다.
    // FetchType 전략 : EAGER, LAZY
    //   EAGER - 조회시 한번에 다 들고 와라 ( 1번 게시글 조회시 한번 조인까지 해라)
    //   LAZY - 처음부터 Board 조회할 때 User 정보를 가져오지 마. 필요할 때 한번 더 조회 해.
    @ManyToOne(fetch = FetchType.LAZY)
    // @OneToMany
    // @OneToOne
    @JoinColumn(name = "user_id") // 외래키 컬럼명 표시 됨
    private User user;

    // @CreationTimestamp : 하이버네이트가 제공하는 어노테이션
    // 특정 하나의 엔티티가 저장이 될 때 현재 시간을 자동으로 저장해 설정
    // now() 명시할 필요 없음
    // pc --> db (자동 날짜 주입)
    @CreationTimestamp
    private Timestamp createdAt;

    // createdAt -> 포멧 하는 메서드 만들어 보기
    public String getTime() {
        return MyDateUtil.timestampFormat(createdAt);
    }

    // 수정 편의 기능 만들기
    public void update(BoardRequest.UpdateDTO updateDTO) {
        // this.username = updateDTO.getUsername(); 삭제 예정
        this.title = updateDTO.getTitle();
        this.content = updateDTO.getContent();

        // 더티체킹 - 변경 감지 동작 과정
        // 1. 최초 조회시 영속성 컨텍스트 1차 캐쉬에 데이터를 스냅샷으로 보관 함.
        // 2. 영속화된 엔티티가(board)의 멤버 변수값이 변경이 된다면
        //    1차에서 보관했던 값과 2차에서 수정된 필드값을 비교 함.
        // 3. 변화가 발생이 되었다면 트랜잭션 커밋 시점에 변경된 필드값 UPDATE 쿼리 자동 생성
        // 4. 물리적은 DB에 반영 됨.
    }

    // 편의 기능 - 게시글 소유자 확인을 위한 기능 추가
    public boolean isOwner(Integer sessionUserId) {
       if(!this.user.getId().equals(sessionUserId)) {
           throw new Exception403("본인이 작성한 게시글이 아닙니다");
       }
       return true;
    }

    //

}

 

BoardController 코드 요약

@Controller 기반의 SSR 처리 : JSON 데이터 대신 Mustache 템플릿(뷰) 경로 문자열을 반환하여 
서버 사이드 렌더링 구현

Model 객체를 통한 데이터 전달 : 조회한 게시글 데이터(boardList, board)를 
model.addAttribute()에 담아 뷰 화면으로 전달

PathVariable 기능 활용 : /board/{id} 처럼 URL 경로에 포함된 가변적인 게시글 고유 ID 값을
안전하게 파싱하여 로직에 활용

DTO 자체 검증(validate()) 수행 : 글 저장 및 수정 프로세스 진행 직전,
DTO 내부의 입력값 유효성 검사를 호출하여 1차 방어선 구축

흐름 제어(redirect)의 정석 활용 : 저장, 수정, 삭제 같은 데이터 변경(POST) 요청 처리 완료 후
"redirect:/"를 통해 화면 새로고침(F5) 중복 요청 방지

 


BoardController 리팩토링 코드 ⬇️

더보기
package com.tenco.blog.board;

import com.tenco.blog.user.User;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;

import java.util.List;

@Slf4j
@Controller // IoC
@RequiredArgsConstructor // DI
public class BoardController {

    private final BoardService boardService;

    /**
     * 게시글 작성 화면 요청
     * @return 페이지 반환
     * 주소설계 : http://localhost:8080/board/save-form
     */
    @GetMapping("/board/save-form")
    public String saveForm(HttpSession httpSession) {
        return "board/save-form";
    }

    /**
     * 게시글 작성 기능 요청
     * @return 페이지 반환
     * 주소설계 : http://localhost:8080/board/save-form
     */
    @PostMapping("/board/save")
    public String saveProc(BoardRequest.SaveDTO saveDTO, HttpSession session) {
        User sessionUser = (User) session.getAttribute("sessionUser");
        saveDTO.validate();
        boardService.save(saveDTO, sessionUser);
        return "redirect:/";
    }


    /**
     * 게시글 목록 화면 요청
     * 주소설계 : http://localhost:8080/
     */
    @GetMapping({"/", "index"})
    public String list(Model model) {
        List<Board> boardList = boardService.findAll();
        model.addAttribute("boardList", boardList);
        return "board/list";
    }

    // 게시글 상세보기 화면 요청
    // http://localhost:8080/board/1
    @GetMapping("/board/{id}")
    public String detailPage(@PathVariable(name = "id") Integer id, Model model) {
        Board board = boardService.findById(id);
        model.addAttribute("board", board);
        return "board/detail";
    }


    // 삭제 기능 요청
    @PostMapping("/board/{id}/delete")
    public String deleteProc(@PathVariable(name = "id") Integer id, HttpSession session) {
        User sessionUser = (User) session.getAttribute("sessionUser");
        boardService.deleteById(id, sessionUser);
        return "redirect:/";
    }


    // http://localhost:8080/board/1/update-form
    // 게시글 수정 화면 요청
    @GetMapping("/board/{id}/update-form")
    public String updateFormPage(@PathVariable(name = "id") Integer id, Model model, HttpSession session) {
       User sessionUser = (User) session.getAttribute("sessionUser");
       Board boardEntity = boardService.findByIdAndCheckOwner(id, sessionUser);
       model.addAttribute("board", boardEntity);
       return "board/update-form";
    }


    @PostMapping("/board/{id}/update")
    public String updateProc(@PathVariable(name = "id") Integer id,
                             BoardRequest.UpdateDTO updateDTO, HttpSession session) {

        User sessionUser =  (User) session.getAttribute("sessionUser");
        updateDTO.validate();
        boardService.updateById(id, updateDTO, sessionUser);

        return "redirect:/board/" + id;
    }

}

 

UserService 코드 요약

클래스 레벨 트랜잭션 최적화 : @Transactional(readOnly = true)를 기본으로 설정하여
조회 성능 최적화 및 영속성 오버헤드 최소화

ifPresent() 활용 중복 검사 : findByUsername() 결과가 존재할 경우(ifPresent)
즉시 Exception400을 터트리는 세련된 중복 방지 로직 구현

orElseThrow() 람다식 예외 처리 : 로그인 및 단건 조회 시 데이터가 없으면 
각각 상황에 맞는 커스텀 예외(400, 404)를 람다식으로 던지도록 설계

더티 체킹 기반 프로필 수정 : updateById() 수행 시 별도 쿼리 없이 영속화된 userEntity.update()를 통해
트랜잭션 종료 시점에 자동 반영

세션 동기화를 고려한 반환 설계 : 수정된 유저 객체를 컨트롤러로 다시 반환하여, 
호출부에서 세션(Session) 정보를 최신화할 수 있도록 배려

UserService 코드

더보기
package com.tenco.blog.user;

import com.tenco.blog._core.errors.Exception400;
import com.tenco.blog._core.errors.Exception404;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * User 관련 비즈니스 로직을 처리하는 Service 계층
 * Controller 와 Repository 사이에서 실제 업무 로직을 담당
 */
@Slf4j
@Service // IoC
@RequiredArgsConstructor // DI
@Transactional(readOnly = true) // 기본적인 읽기 전용 트랜잭션 처리 , 조회시 더티 체킹 안 일어남
public class UserService {

    private final UserRepository userRepository;

    /**
     * 회원 가입 처리
     * @param joinDTO (사용자 회원가입 요청 정보)
     * @return User (저장된 사용자 정보)
     */
    @Transactional
    public User join(UserRequest.JoinDTO joinDTO) {
        // 1. 로그 기록 - 회원가입 요청 정보
        // 2. 사용자명 중복 검사 (데이베이스 조회)
        // 3. username 존재하면 Exception400 예외 발생
        // 4. JoinDTO -> User 객체로 변환 처리
        // 5. 데이터 베이스에 사용자 정보 저장
        // 6. 로그 기록 - 회원가입 완료
        // 7. 저장된 사용자 정보 컨트롤러로 반환
        log.info("회원가입 서비스 시작");
        // 조건 - 중복된 사용자이름이 없는것이 정상 동작
        // ifPresent -> 존재 여부 확인
        // 사용자명 중복 체크
        userRepository.findByUsername(joinDTO.getUsername()).ifPresent(user -> {
            log.warn("회원가입 실패 - 중복된 사용자명 : {}", user.getUsername());
            throw new Exception400("이미 존재하는 사용명입니다");
        });

        User user = joinDTO.toEntity();
        User savedUserEntity = userRepository.save(user);
        log.info("회원 가입 서비스 완료 - id : {}", savedUserEntity.getId());
        return savedUserEntity;
    }

    /**
     * 로그인 처리
     * @param loginDTO (사용자가 요청한 로그인 정보)
     * @return User(조회된 정보 세션 저장용)
     */
    public User login(UserRequest.LoginDTO loginDTO) {
        // 1. 로그 기록 - 로그인 요청 정보 (사용자명)
        // 2. 사용자 이름과 비밀번호로 데이터베이스에서 조회
        // 3. 인증 정보기 일치하지 않으면 Exception400 예외 처리
        // 4. 로그 기록 - 로그인 성공 정보
        // 5. 인증된 사용자 정보 컨트롤러 단으로 반환 (세션 저장용)
        log.info("로그인 서비스 시작");
        User userEntity = userRepository.findByUsernameAndPassword(loginDTO.getUsername(), loginDTO.getPassword())
                .orElseThrow(() -> {
                    log.warn("로그인 실패 - 사용자 이름 또는 사용자 비번 잘못 입력");
                    return new Exception400("사용자명 또는 비밀번호가 올바르지 않습니다");
                });
        log.info("로그인 성공 - 사용자명 : {} ", loginDTO.getUsername());
        return userEntity;
    }

    /**
     * 사용자 정보 조회 (프로필 정보 보기 활용)
     * @param id (User PK)
     * @return UserEntity
     */
    public User findById(Integer id) {
        log.info("사용자 정보 서비스 시작");
        return userRepository.findById(id).orElseThrow(() -> {
            log.warn("사용자 정보 조회 실패");
            return new Exception404("사용자 정보를 찾을 수 없습니다");
        });
    }


    /**
     * 사용자 정보 수정 처리 (프로필 업데이트)
     * @param id  (User PK)
     * @param updateDTO (사용자가 요청한 데이터)
     * @return User
     */
    @Transactional
    public User updateById(Integer id, UserRequest.UpdateDTO updateDTO) {
        // 1. 로그 기록 - 회원 정보 수정 요청 정보 (ID)
        // 2. 수정하려면 사용자 정보 조회
        // 3. 예외 처리 Exception400
        // 4. 더티 체킹을 통한 사용자 정보 수정(JPA 영속성 컨텍스트 활용)
        // 5. 로그 기록 - 수정 완료 로그 처리
        // 6. 수정된 사용자 정보 컨트롤로 단으로 반환 (세션 동기화 용)
        log.info("회원정보 서비스 시작");
        User userEntity = findById(id);
        // 더티 체킹 활용
        userEntity.update(updateDTO);
        log.info("회원정보 수정 완료 - 사용 ID : {}", userEntity.getId());
        return userEntity;
    }
}

User 코드 요약

@Entity 및 @Table 설정 : User 클래스를 데이터베이스의 user_tb 테이블과 매핑하여 
JPA 영속성 컨텍스트의 관리 대상으로 등록

@Column(unique = true) 적용 : 회원가입 시 아이디 중복을 데이터베이스 레벨에서
원천 차단하기 위해 username 컬럼에 유니크 제약 조건 부여

@Builder 패턴의 생성자 제한 적용 : 클래스 레벨이 아닌 필요한 필드만 모은 커스텀 생성자 위에
@Builder를 붙여 안전한 객체 생성 유도

@CreationTimestamp 자동화 : 회원가입이 완료되어 영속화(persist)되는 시점에 
서버의 현재 시간을 createdAt 컬럼에 자동으로 주입

객체지향적 도메인 메서드(update) 구현 : Setter를 배제하고 무분별한 데이터 수정을 막기 위해
엔티티 내부에서 스스로 데이터를 변경하도록 설계

User 코드(수정 기능 추가)

더보기
package com.tenco.blog.user;

import jakarta.persistence.*;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;

import java.sql.Timestamp;

@Data
@NoArgsConstructor
@Table(name = "user_tb")
@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    // 사용자명 중복 방지를 위한 유니크 제약 조건 설정
    @Column(unique = true)
    private String username;

    private String password;
    private String email;
    // 엔티티가 영속화 될 때 자동으로 현재 시간을 주입해라 pc -> db
    @CreationTimestamp
    private Timestamp createdAt;

    @Builder
    public User(Integer id, String username, String password, String email, Timestamp createdAt) {
        this.id = id;
        this.username = username;
        this.password = password;
        this.email = email;
        this.createdAt = createdAt;
    }

    // 편의 기능 추가 - 회원 정보 수정
    public void update(UserRequest.UpdateDTO updateDTO) {
        this.password = updateDTO.getPassword();
        // Dirty Checking 처리
    }
}

UserController 코드 요약

인증 및 가입 흐름 제어 : 로그인/회원가입의 화면 요청(GET)과 기능 요청(POST)을 분리하여
직관적인 라우팅 설계 완수

DTO 유효성 검사 자동화 : loginProc, joinProc, updateProc 진입 직후 
validate()를 호출해 올바른 데이터만 서비스로 전달

HttpSession 기반 상태 유지 : 로그인 성공 시 세션에 유저 엔티티를 저장(sessionUser)하고,
로그아웃 시 세션을 무효화(invalidate) 처리

핵심 기술 - 세션 동기화 구현 : 프로필 수정 시, DB 변경 후 리턴받은 최신 객체로
세션 속성을 갱신하여 뷰 화면에 즉시 반영 성공

POST-Redirect-GET 패턴 적용 : 가입, 로그인, 수정 등의 쓰기 작업 완료 후 
redirect를 사용해 브라우저 새로고침으로 인한 중복 요청 방지

UserController코드(수정)

더보기
package com.tenco.blog.user;

import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

@Slf4j
@Controller // IoC
@RequiredArgsConstructor // DI 처리
public class UserController {

    private final UserService userService;


    // 프로필 수정 기능 요청
    @PostMapping("/user/update")
    public String updateProc(UserRequest.UpdateDTO updateDTO, HttpSession session) {
        updateDTO.validate();
        User sessionUser = (User) session.getAttribute("sessionUser");
        User userEntity = userService.updateById(sessionUser.getId(), updateDTO);
        // 세션 동기화 처리
        session.setAttribute("sessionUser", userEntity);
        return "redirect:/";
    }

    // 프로필 화면 요청
    @GetMapping("/user/update-form")
    public String updateFormPage(HttpSession session, Model model) {
        User sessionUser = (User) session.getAttribute("sessionUser");
        User userEntity = userService.findById(sessionUser.getId());
        model.addAttribute("user", userEntity);
        return "user/update-form";
    }

    // 로그인 화면 요청
    // 주소 설계 - http://localhost:8080/login-form
    @GetMapping("/login-form")
    public String loginFormPage() {
        // 인증 검사 x , 유효성 x
        return "user/login-form";
    }

    // 로그인 기능 요청
    @PostMapping("/login")
    public String loginProc(UserRequest.LoginDTO loginDTO, HttpSession session) {
        // 인증 검사 x, 유효성 검사 o
        loginDTO.validate();
        User userEntity = userService.login(loginDTO);
        session.setAttribute("sessionUser", userEntity);
        return "redirect:/";
    }


    // 로그아웃 기능 요청
    @GetMapping("/logout")
    public String logout(HttpSession session) {
        // 세션 메모리에 내 정보를 없애 버림
        session.invalidate();
        return "redirect:/";
    }

    // 회원 가입 화면 요청
    // 주소 설계 - http://localhost:8080/join-form
    @GetMapping("/join-form")
    public String joinFormPage() {
        return "user/join-form";
    }

    // 회원 가입 기능 요청
    // 주소 설계 - http://localhost:8080/join
    @PostMapping("/join")
    public String joinProc(UserRequest.JoinDTO joinDTO) {
        //  인증검사 x, 유효성 검사 하기 o
        joinDTO.validate();
        userService.join(joinDTO);
        return "redirect:/login-form";
    }

}