Develop

[Spring Boot] 게시판 - 01. 프로젝트 환경설정 , Entity 설계, 회원 관리 API 개발 본문

백엔드/게시판 만들기

[Spring Boot] 게시판 - 01. 프로젝트 환경설정 , Entity 설계, 회원 관리 API 개발

230801 2025. 11. 19. 05:29

안녕하세요  .ᐟ

앞으로 Spring Boot 를 활용한 게시판 프로젝트를 진행해보려 합니다 ~

 

오늘은 환경설정부터 엔티티 설계, 회원관리 API 개발까지 진행했습니다.

(몇달전에 살짝 작업하다가 다시 ...시작합니다ㅋㅋㅋ)

 

1. 프로젝트 환경 설정

기술 스택

  • Java 21
  • Spring Boot 3.5.5
  • MySQL 8.4.1
  • Spring Data JPA
  • Spring Security
  • Lombok

 

2. Entity 설계

BaseEntity - 공통 필드 추상화

  • 팀프로젝트 당시 BaseEntity를 이용해서 공통응답을 내려줬었는데, 아래와 같은 장점이 있어 이번에도 사용해보려 합니다.
    • 여러 Entity 에서 반복사용되는 필드를 한곳에 작성하고 상속받아 사용할 수 있다. (중복 관리)
    • 시간 필드들을 일관성있게 관리할 수 있다.
    • Sofe Delete를 구현할 수 있다. (deletedAt으로 논리 삭제 구현)
@MappedSuperclass // 상속받는 Entity들이 필드를 컬럼으로 인식
@EntityListeners(value = {AuditingEntityListener.class})  //JPA Auditing으로 생성/수정 시간 자동 관리
@Getter
@NoArgsConstructor
public abstract class BaseEntity {

    @CreatedDate
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column
    private LocalDateTime updatedAt;

    @Column
    private LocalDateTime deletedAt;
}

 

 

 

UserEntity

  • 기존에는 Entity를 DB 맵핑, 연관관계 설정 위주로 사용해왔는데 ,이번에는 비밀번호 변경에 필요한 검증로직을 캡슐화 해봤습니다.
  • 비밀번호를 BCrypt로 암호화 시 약 60자 정도로 변환되기 때문에 length 를 넉넉하게 잡아줍니다.
@Entity
@Table(name = "users")
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 20)
    private String name;

    @Column(length = 50)
    private String nickname;

    @Column(nullable = false, unique = true, length = 50)
    private String email;

    @Column(nullable = false, length = 255)  // BCrypt 암호화 대비
    private String password;

    @Column(length = 500)
    private String profileImage;

    @Column(nullable = false)
    @Enumerated(EnumType.STRING)
    @Builder.Default
    private Role role = Role.USER;

    @Column
    private LocalDateTime lastLoginAt;

    @Column
    @Builder.Default
    private int loginCount = 0;

    // 비즈니스 로직
    public void updateProfile(String nickname, String profileImage) {
        if (nickname != null) {
            if (nickname.isBlank()) {
                throw new IllegalArgumentException("Nickname cannot be empty");
            }
            if (nickname.length() > 20) {
                throw new IllegalArgumentException("Nickname too long (limit: 20)");
            }
            this.nickname = nickname;
        }
        
        if (profileImage != null) {
            this.profileImage = profileImage;
        }
    }

    public void changePassword(String newPassword) {
        if (newPassword == null || newPassword.isBlank()) {
            throw new IllegalArgumentException("Password cannot be empty");
        }
        
        if (newPassword.length() > 50) {
            throw new IllegalArgumentException("Password too long (limit: 50)");
        }
        
        if (!isValidPassword(newPassword)) {
            throw new IllegalArgumentException(
                "Password must be at least 8 characters and include letters, numbers, and special characters"
            );
        }
        
        this.password = newPassword;
    }

    private boolean isValidPassword(String password) {
        String pattern = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,}$";
        return password.matches(pattern);
    }
}

 

3. DTO 활용

  • 팀프로젝트 당시 HTTP 메서드별로 Response DTO 를 만들어서 반환하는 연습을 했습니다.
  • DTO 내부에 of () 정적 팩토리 메서드를 이용해서 여러 형태의 반환값을 만들어서 사용했는데, 이번에는 빌더패턴 추가활용해서 Entity -> DTO 변환로직을 캡슐화 했습니다.
  • 용도에맞게 단건조회 (DetailResponse) 와 목록조회(ListResponse) 로 분리해 설계했습니다.
package com.cho.board.controller.user.dtos;

import com.cho.board.domain.user.Role;
import com.cho.board.domain.user.User;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class UserDetailResponse {

    private Long id;
    private String name;
    private String nickname;
    private String email;
    private String profileImage;
    private Role role;
    private LocalDateTime createdAt;

    public static UserDetailResponse from(User user) {
        return UserDetailResponse.builder()
            .id(user.getId())
            .name(user.getName())
            .nickname(user.getNickname())
            .email(user.getEmail())
            .profileImage(user.getProfileImage())
            .role(user.getRole())
            .createdAt(user.getCreatedAt())
            .build();
    }

}

 

4. Service 레이어

  • PasswordEncoder 를 이용해서 암호화 합니다. (SecurityConfig 에 @Bean 으로 등록)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
  • @Transactional 을 이용해서 변경을 감지합니다. (Dirty Checking)
    • JPA 의 영속성컨텍스트가 Entity 의 상태변화를 추적함
    • 트랜잭션 커밋 시점에 변경된 필드만 자동 update 됨
    • repository.save() 호출 불 필요
@Service
@Transactional
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public User create(UserCreateRequest request) {

        if (userRepository.findByName(request.getNickname()).isPresent()) {
            throw new DataIntegrityViolationException("Nickname is already exists.");
        }

        if (userRepository.findByEmail(request.getEmail()).isPresent()) {
            throw new DataIntegrityViolationException("Email is already exists.");
        }

		// 비밀번호 암호화
        String encodedPassword = passwordEncoder.encode(request.getPassword());

        User user = User.builder()
            .name(request.getName())
            .nickname(request.getNickname())
            .email(request.getEmail())
            .password(encodedPassword)
            .profileImage(request.getProfileImage())
            .role(Role.USER)
            .build();
        return userRepository.save(user);
    }

    @Transactional(readOnly = true)
    public List<User> findAll() {
        return userRepository.findAll();
    }

    @Transactional(readOnly = true)
    public User findById(Long userId) {
        return userRepository.findById(userId)
            .orElseThrow(
                () -> new EntityNotFoundException("User not found with ID : " + userId));
    }

    public User update(Long userId, UserUpdateRequest request) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new EntityNotFoundException("User not found"));

		// 프로필 변경
        user.updateProfile(request.getNickname(), request.getProfileImage());
		
        // 비밀번호 변경 (있을 경우)
        if (request.getPassword() != null && !request.getPassword().isBlank()) {
            if (passwordEncoder.matches(request.getPassword(), user.getPassword())) {
                throw new IllegalArgumentException("New password must be different");
            }

            String encodedPassword = passwordEncoder.encode(request.getPassword());
            user.changePassword(encodedPassword);
        }

        return user;
    }

    public void delete(Long userId) {
        User user = userRepository.findById(userId)
            .orElseThrow(IllegalArgumentException::new);

        userRepository.delete(user);
    }
}

 

5. 느낀 점

  • Entity에 비즈니스 로직을 포함하는것이 도메인 주도 설계(DDD)의 핵심 개념임을 알게 되었습니다.
    • 단순히 데이터를 담는 객체가 아니라, 스스로 검증하고 상태를 관리한다는 의미에서 도메인 주도 설계라고 한답니다.
  • 기존에는 Entity를 직접 반환했지만, DTO 로 분리해서 보안과 유지보수성을 높이고, 에러위험을 낮출 수 있었습니다.
  • 그동안 update() 시 repository.save(); 를 명시적으로 호출했는데, JPA 의 Dirty Checking 을 활용해 변경된 필드만 자동으로 update 되어 코드가 간결해졌습니다.