Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |
Tags
- 오블완
- cs
- mysql 표 출력
- 오일러 경로
- spring boot
- zsh theme
- 분할정복 방법
- 인프런워밍업클럽
- VI
- 욕심쟁이 방법
- 맥
- MySQL
- table status
- 스터디2기
- 인프런
- 티스토리챌린지
- 터미널
- 순차탐색
- Pager
- CS스터디
- mycli
- 데이크스트라
- 알고리즘
- 네트워킹데이
- Less
- oh-my-zsh
- zsh
- 동적 프로그래밍 방법
- mysql 표
- 이진탐색
Archives
- Today
- Total
Develop
[Spring Boot] 게시판 - 01. 프로젝트 환경설정 , Entity 설계, 회원 관리 API 개발 본문
안녕하세요 .ᐟ
앞으로 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 되어 코드가 간결해졌습니다.

'백엔드 > 게시판 만들기' 카테고리의 다른 글
| [Spring Boot] 게시판 - 06. 단위 테스트 작성하기 (Mockito & AssertJ) (0) | 2025.11.24 |
|---|---|
| [Spring Boot] 게시판 - 05. Validation 검증 (입력값 유효성 검사) (1) | 2025.11.23 |
| [Spring Boot] 게시판 - 04. 전역 예외 처리 (Global Exception Handler) (0) | 2025.11.22 |
| [Spring Boot] 게시판 - 03. 댓글(Comment) API 구현 (대댓글, 소프트 삭제) (0) | 2025.11.21 |
| [Spring Boot] 게시판 - 02. Post API 개발 (페이징 처리, IntelliJ HTTP Client) (0) | 2025.11.20 |