챌린지를 진행하는 중에는 일정이 너무 빠듯해서 이때 부터는 개발 일지를 제때 올리지 못했는데요, 이제 쌓아둔 노트를 하나둘씩 정리해서 포스팅 해보려 합니다 😏
제가 맡은 기능은 다음과 같습니다.
- Swagger-UI 설정 및 예시 코드 작성 ✔
- 자체 회원 가입/로그인 & 카카오 API를 사용한 회원 가입/로그인 기능 구현
- 이메일을 아이디로 하며, 이메일과 닉네임은 unique한 값
- 6km이내의 지역을 기준으로 동네 소속이 바뀜
- 시간 관계상 비밀번호 찾기나 아이디 찾기 등의 기능은 생략
- 로그인을 하면 로그아웃을 하거나 한 달이상의 시간이 지나기 전에는 회원정보를 들고있어 메인 url에 접속하면 화면이 랜딩됨
- 회원 조회/수정/탈퇴, 로그아웃 기능 구현
- 프로필 화면에서 프로필 사진 변경, 닉네임 수정, 로그아웃, 탈퇴하기 가능
- 모임 신청하기, 신청 취소하기, 모임장이 모임원 강퇴하기 구현
- + 추가로 맡은 역할)
- 모임 글 생성/수정 기능 구현
- 작성 시 모임 제목, 소개글, 사진 1~3개, 대분류 카테고리 1개에 포함되는 태그 1~3개 선택, 연락수단, 모임 날짜를 입력받음
- 예외 처리 인터페이스 작성 및 exception 컨트롤러 구현 ✔
- 모임 글 생성/수정 기능 구현
이번 포스팅에서는 엔티티 구현 및 '자체' 회원가입, 회원 조회, 회원 수정, 회원 탈퇴 기능 및 모임 관련 기능에 대해 포스팅 하겠습니다.
로그인/로그아웃 기능은 다음 포스팅에서 다룰게요!
User 엔티티 구현
앞서 설계한 ERD에서 User는 다른 엔티티를 참조하지 않습니다(초반에는 양방향으로 잘못걸어서 복잡했지만... 👀).
<User 기준>
- Users 1<--* Likes
- Users 1<--* Socialing *---1 Social (Users와 Social 은 다대다 관계이므로 연결 테이블을 만듬)
- Users 1<--* Post
- Users 1<--* Comment
그래서 User 클래스에서 연관관계 설정은 할 필요 없고, 코드는 다음과 같습니다😵 차근차근 뜯어보자구요...!
User.java
@Getter
@Entity
@Table(name = "USERS", uniqueConstraints =
{@UniqueConstraint(name = "EMAIL_NICKNAME_UNIQUE", columnNames = {"email","nickname"})})
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Slf4j
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long id;
@Enumerated(value = EnumType.STRING)
@Column(name = "user_type")
private UserType userType; // 기본 회원, 카카오 회원 구분
@Column(name = "sns_id")
private Long snsId; //sns 계정 회원가입 유무 확인용
@NotNull
private String email; // 아이디
private String password; //카카오 유저는 비밀번호가 없음
@NotNull
@Column(name = "username")
private String username;
@NotNull
@Column(name = "nickname")
private String nickname;
@NotNull
@Column(name = "phone_number")
private String phoneNumber;
@NotNull
@Column(name = "dong_code")
private Long dongCode;
@NotNull
@Column(name = "dong_name")
private String dongName;
private String profile;
private String bio;//한마디 소개글
@Column(length = 1000, name = "refresh_token")
private String refreshToken; //JWT
@Enumerated(value = EnumType.STRING)
@Column(name = "user_status")
private UserStatus userStatus;
public void encodePassword(PasswordEncoder passwordEncoder){
password = passwordEncoder.encode(password);
}
public void setDefaultUser(){
userType = UserType.DEFAULT;
userStatus = UserStatus.ACTIVATED;
}
public void setKakaoUser(Long snsId){
this.snsId = snsId;
userType = UserType.KAKAO;
userStatus = UserStatus.ACTIVATED;
}
public void setWithdrawStatus(){
userStatus = UserStatus.WITHDRAW;
}
public Integer getRegionCodeFromDongCode(){
String s = dongCode.toString();
Integer regionCode = Integer.parseInt(s.substring(0, 5));//앞 5자리가 시군구 코드
log.info("dongCode={}, regionCode={}",dongCode, regionCode);
return regionCode;
}
@Builder
public User(@NotNull UserType userType, @NotNull String email, @NotNull String password, @NotNull String username, @NotNull String nickname, @NotNull String phoneNumber, @NotNull Long dongCode, @NotNull String dongName, @NotNull UserStatus userStatus, String bio) {
this.userType = userType;
this.email = email;
this.password = password;
this.username = username;
this.nickname = nickname;
this.phoneNumber = phoneNumber;
this.dongCode = dongCode;
this.dongName = dongName;
this.userStatus = userStatus;
this.bio = bio;
}
//==수정 메서드==//
public void updateNickname(String nickname){
this.nickname = nickname;
}
public void updateBio(String bio){
this.bio = bio;
}
public void updateProfile(String profile){
this.profile = profile;
}
public void updateRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
public void updateRegion(Long dongCode, String dongName){
this.dongCode = dongCode;
this.dongName = dongName;
}
public void deleteRefreshToken() {
this.refreshToken = null;
}
}
refreshToken 이나 UserType 등의 필드는 로그인이 관련된 것들이라 다음 포스팅에서 다룰게요 💦
그리고 여기서 갑작스럽지만..! 기존 프론트엔드 개발자 한 분이 하차하시게 되어 뒤늦게 프론트 엔드 한 분께서 새로 들어오셨습니다! 🤗 맡은 기능들이 많이 겹쳐서 주로 이 분과 협업을 같이 하게 되었어요!! 참 신기합니다.
지역 설정
우선 넘블이 제공한 와이어프레임의 MVP 요구사항에 6km이내의 지역을 기준으로 게시판이 분리된다는 사항이 있었어요.
프론트 엔드분과 협의 후 국토교통부에서 제공하는 읍면동 Open API 를 사용하기로 했습니다.
다음과 같이 프론트에서 행정구역코드와 행정구역명을 받아와 User 엔티티의 dongCode, dongName으로 저장합니다.
서울 기준으로는 다음과 같이 행정구역 코드의 앞 5자리가 구로 나뉩니다.
다른 지역은 이렇게 시.군.구로도 나뉩니다.
예를 들어 41173102가 읍면동 코드이면, 앞 5자리인 41173가 시군구 코드(더 큰 범위)입니다.
일단은 서울을 기준으로 잡기로 하고, 동 단위 보다는 구 단위를 기준으로 동네 필터링을 나누면 좋겠다는 디자이너 분의 의견이 있었습니다.
이 둘을 분리해서 저장하기 보다는 모임 생성/피드 게시글 생성 등에 시군구 코드가 필요할 때 문자열을 잘라주는 메서드를 작성했습니다. (분리해서 저장하면 추후 행정구역코드 등이 변경될 경우 문제가 생길 수도 있다고 판단했습니다)
실무에서는 데이터를 삭제하거나 회원 탈퇴를 하더라도 복구할 수 있도록 DB에서 직접 삭제하는 경우는 드물다고 들었습니다. 그래서 User에 UseStatus라는 enum 타입을 필드로 둬서 회원 탈퇴를 할 경우에 상태만 변경하도록 구현했어요.
UserStatus.java
public enum UserStatus {
ACTIVATED, WITHDRAW
}
User.java
...
@Enumerated(value = EnumType.STRING)
@Column(name = "user_status")
private UserStatus userStatus;
public void setWithdrawStatus(){
userStatus = UserStatus.WITHDRAW;
}
...
※ enum 타입을 쓸 때 주의 할 점 !! 확장성을 고려해서 꼭 EnumTye.STRING 타입으로 써야해요.
회원 Restful-API 설계
회원 API 는 POST 기반의 컬렉션으로 다음과 같이 설계했습니다. (실무에서도 대부분 컬렉션을 쓴다고 해요)
- /users 자체 회원 가입 → POST
- /users/{id} 회원 탈퇴 → DELETE
- /users/{userType} SNS 회원 가입 → POST
- /users/sns/{userType} SNS 회원 탈퇴 → DELETE
- /users/{id} 회원 프로필 조회 → GET
- /users/{id} 회원 수정 → PUT
컬렉션(Collection) : 서버가 관리하는 리소스 디렉토리. 여기서 컬렉션은 /users
회원수정은 사실, 클라이언트에서 데이터 전부를 서버에 전송하기 떄문에 개념적으로는 PATCH를 사용하는게 제일 좋다고 배웠습니다. 전체 수정을 할 때 PUT을 사용한다고 알고있구요.
그런데 게시글 수정일 경우 PUT을 써도 된다고 하고, 애매하면 POST를 써도 된다고 하니 고민이 되었습니다.
PUT VS. PATCH in Spring
JPA 2편 강의를 학습했을 때에는 이렇게 배웠습니다.
그래서 게시글 수정 시에는 PATCH를 써보려고 했는데요. 그런데 찾아보니 PATCH를 쓰려면 Spring 환경의 서버 단에서 별도의 로직이 필요하다고 합니다...!😔 그래서 PATCH말고 PUT을 사용하기로 했습니다.
물론 이외에도 다음과 같은 방법들이 있다고 해요!!
- mapstruct 같은 라이브러리 이용하기
- @NotNull 등의 어노테이션을 이용해 Exception 발생
- null 체크 로직 직접 생성하기
그러면 이제 내부로직을 어떻게 설계하는지 선택하면 되는데, 다음 두 가지 방법이 있습니다.
1. 변경되지 않는 값을 포함해 모든 값을 다 보내주도록 설계한다. (DTO에 @NotNull)
2. 일부만 보내도 상관 없도록 설계한다. (이 경우 null 체크 필요)
2번이 프론트분께서 편할 것 같아서 2번으로 구현했습니다.
- 참고한 포스팅 👍 : https://yeonyeon.tistory.com/183
회원 가입 & 회원 수정
UserController.java
@Tag(name = "user", description = "회원 API")
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final SnsAPIService snsAPIService;
private final AuthTokenProvider authTokenProvider;
private final CookieProvider cookieProvider;
@Operation(summary = "회원가입",
description = "unique field 중복 시 errorCode -101(이메일), -102(닉네임), -103(이메일,닉네임)이 반환됩니다. bio만 null availabe 합니다.")
@PostMapping("/users")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "CREATED"),
@ApiResponse(responseCode = "400", description = "BAD REQUEST")
})
public ResponseEntity<?> join(@RequestBody @Valid final UserDefaultJoinRequestDTO userDefaultJoinRequestDTO, UriComponentsBuilder uriBuilder){
final User createdUser = userService.createDefaultUser(userDefaultJoinRequestDTO);
return ResponseUtils.getCreatedUserResponse(uriBuilder, createdUser.getId());
}
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "회원 수정", description = "모든 변수 null available")
@PutMapping("/users/{id}")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK"),
@ApiResponse(responseCode = "401", description = "UNAUTHORIZED"),
@ApiResponse(responseCode = "403", description = "BAD REQUEST"),
@ApiResponse(responseCode = "404", description = "NOT FOUND")
})
public ResponseDTO<UserProfileDTO> modifyUser(
@AuthenticationPrincipal CustomUserDetails user,
@PathVariable Long id,
@RequestBody @Valid final UserModifyRequestDTO userModifyRequestDTO){
checkPathResource(user.getUserId(), id);
final User modifiedUser = userService.modify(user.getUserId(), userModifyRequestDTO);
UserProfileDTO userProfileDTO = new UserProfileDTO(modifiedUser);
return new ResponseDTO<>(userProfileDTO, "정상 수정 처리 되었습니다.");
}
...
}
여기서 잠깐... ㅎㅎ POST 신규 자원등록의 특징을 짚고 넘어갑시다.
회원가입 같은 경우, 상태코드는 201 Created로 설정하고 Location에 새로 등록된 리소스 URI를 생성해서 전달해야해요.
또한 이후에 sns 회원가입 기능도 구현하면 이 부분이 중복되기 때문에 response 응답을 처리하는 로직을 따로 분리했습니다.
public class ResponseUtils {
public static ResponseEntity<?> getCreatedUserResponse(UriComponentsBuilder uriBuilder, Long id) {
URI location = uriBuilder.path("/user/{id}")
.buildAndExpand(id).toUri();
return ResponseEntity.created(location)
.body(new ResponseDTO<>(null, "회원가입 처리되었습니다."));
}
}
으아악 아니 오타가 ... 오탈자 발견! user 아니고 users로 바꿔야겠네요 😂
UserServiceImpl.java
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
...
private final PasswordEncoder passwordEncoder;
@Transactional
@Override
public User createDefaultUser(UserDefaultJoinRequestDTO joinDTO) {
validateDuplicate(joinDTO.getEmail(), joinDTO.getNickname());
//중복X -> 회원가입 처리
User user = joinDTO.toEntity();
user.encodePassword(passwordEncoder);
user.setDefaultUser();
return userRepository.save(user);
}
@Transactional
@Override
public User modify(Long userId, UserModifyRequestDTO userDTO) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new EntityNotExistsException(EntityNotExistsExceptionType.NOT_FOUND_USER));
//user 수정
if(userDTO.getNickname() != null) user.updateNickname(userDTO.getNickname());
if(userDTO.getBio() != null) user.updateBio(userDTO.getBio());
if(userDTO.getProfile() != null) user.updateProfile(userDTO.getProfile());
if(userDTO.getDongCode() != null && userDTO.getDongName() != null){
user.updateRegion(userDTO.getDongCode(), userDTO.getDongName());
}
return user;
}
//중복 검증
private void validateDuplicate(String email, String nickName) {
if(userRepository.existsByEmailAndNickname(email, nickName)){
throw new UserInvalidInputException(UserInvalidInputExceptionType.ALREADY_EXIST_EMAIL_AND_NICKNAME);
}
else if(userRepository.existsByEmail(email)){
throw new UserInvalidInputException(UserInvalidInputExceptionType.ALREADY_EXISTS_EMAIL);
}
else if(userRepository.existsByNickname(nickName)){
throw new UserInvalidInputException(UserInvalidInputExceptionType.ALREADY_EXISTS_NICKNAME);
}
}
}
회원가입 시 패스워드 암호화에 사용되는 인코더는 SecurityConfig에서 다음과 같이 빈으로 주입받습니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
...
@Bean
public PasswordEncoder getPasswordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
PasswordEncoderFactories.createDelegatingPasswordEncoder();
이 메서드를 호출하면 PasswordEncoder 인터페이스의 구현체인 BCryptPasswordEncoder가 생성됩니다.
BCryptPasswordEncoder는 Spring Security에서 제공하는 클래스로, 생성자의 인자 값을 통해서 해시의 강도를 조절할 수 있습니다. 다음과 같은 메서드를 제공합니다.
- encdoe() 메서드 : SHA-1, 8바이트로 결합된 해쉬, 랜덤 하게 생성된 솔트(salt)를 지원
- matchers() 메서드 : 패스워드 일치 여부를 확인하여 boolean 타입을 반환
- upgradeEncoding() : 암호를 한번 더 인코딩해서 보안을 강화함
BCrypt 해싱 함수를 사용한 이유는 이 포스팅에 정리해두었습니다.
회원 조회
UserController.java
@Tag(name = "user", description = "회원 API")
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final SnsAPIService snsAPIService;
private final AuthTokenProvider authTokenProvider;
private final CookieProvider cookieProvider;
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "회원 프로필 조회")
@GetMapping("/users/{id}")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK"),
@ApiResponse(responseCode = "401", description = "UNAUTHORIZED"),
@ApiResponse(responseCode = "403", description = "BAD REQUEST"),
@ApiResponse(responseCode = "404", description = "NOT FOUND")
})
public ResponseDTO<?> getUserInfo(
@AuthenticationPrincipal CustomUserDetails user,
@PathVariable Long id){
checkPathResource(user.getUserId(), id);
final User findUser = userService.getUserById(user.getUserId());
UserProfileDTO userProfileDTO= new UserProfileDTO(findUser);
return new ResponseDTO<>(userProfileDTO, "프로필 조회 결과");
}
public void checkPathResource(Long authId, Long pathId){
if(!authId.equals(pathId)) throw new ForbiddenException(ForbiddenExceptionType.USER_UN_AUTHORIZED);
}
}
UserServiceImpl.java
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
...
@Override
public User getUserById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new EntityNotExistsException(EntityNotExistsExceptionType.NOT_FOUND_USER));
}
...
}
UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);//이메일로 회원 조회
..
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Optional<User> findReadOnlyById(Long userId); //더티체킹 필요없이 조회만 필요할 때 사용
..
boolean existsByEmailAndNickname(String email, String nickName);
boolean existsByEmail(String email);
boolean existsByNickname(String nickName);
}
여기서 findReadOnlyById를 쓴 이유를 설명하겠습니다.
@QueryHint의 readOnly 와 @Transaction의 readOnly 차이
이 둘의 readonly 옵션의 차이점은 다음과 같습니다.
- @QueryHint의 readonly: 더티체킹을 위한 스냅샷 생성을 막아줘서 메모리를 절약함
- @Transactional의 readonly: DB에 반영할 것이 없다는 것을 암시하여 영속성 context flush 를 하지 않도록 하여 dirty checking을 하지 않음
다른 기능 구현 시 회원 엔티티를 조회해야할 때, 회원 엔티티를 변경하지 않는 경우는 다음과 같이 @QueryHints를 사용해 나름 최적화를 했는데요.
그래서 사실 프로젝트 진행 당시에는 배운대로 조회만 필요할 때에는 @QueryHints를 사용해 나름 최적화를 했는데요.
나중에 궁금해서 찾아보니 spring 5.1부터 @Transactional의 readonly 만 써도 스냅샷 생성이 막힌다고 합니다. 그냥 @Transactional의 readonly 이거만 쓰면 된다는 거죠! 😲 고쳐야 할 코드가 늘었네용 ... ㅎ
회원 탈퇴
UserController.java
@Tag(name = "user", description = "회원 API")
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final SnsAPIService snsAPIService;
private final AuthTokenProvider authTokenProvider;
private final CookieProvider cookieProvider;
...
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "회원 탈퇴", description = "카카오 계정인 경우 errorCode -123가 반환됩니다.")
@DeleteMapping("/users/{id}")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK"),
@ApiResponse(responseCode = "401", description = "UNAUTHORIZED"),
@ApiResponse(responseCode = "403", description = "BAD REQUEST"),
@ApiResponse(responseCode = "404", description = "NOT FOUND")
})
public ResponseDTO<?> deleteUser(
@AuthenticationPrincipal CustomUserDetails user,
@PathVariable Long id){
checkPathResource(user.getUserId(), id);
if(user.getUserType() == UserType.KAKAO){
throw new UserInvalidInputException(UserInvalidInputExceptionType.CANT_DELETE_KAKAO_USER);
}
User findUser = userService.getUserById(id);
userService.changeToWithdrawnUser(findUser);
return new ResponseDTO<>(null, "정상 탈퇴되었습니다");
}
}
회원 탈퇴 시에는 다음 과정이 발생합니다.
- 회원의 모임 참여 취소 --> socialing row 제거
- 회원이 모임장인 모임 삭제 --> social row 제거
- 회원이 작성한 댓글 --> comment의 deleted 필드 true로 변경
댓글의 경우, 댓글 기능을 맡으신 분의 의견에 따라 삭제하지 않고 deleted boolean 필드를 설정하기로 했습니다.
UserServiceImpl.java
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final SocialRepository socialRepository;
private final SocialingRepository socilaingRepository;
private final CommentRepository commentRepository;
private final SnsAPIService snsAPIService;
private final PasswordEncoder passwordEncoder;
...
@Transactional
@Override
public void changeToWithdrawnUser(User user) {
//DB 에서 삭제하지 않고 상태만 변경
user.setWithdrawStatus();
//연결된 소셜링 삭제(참여한 모임)
socilaingRepository.deleteAllByUserId(user.getId());
//내가 모임장인 소셜 삭제
socialRepository.deleteAllByUserId(user.getId());
//작성한 댓글 deleted true로 변경
List<Comment> commentList = commentRepository.findAllByUserId(user.getId());
if(commentList != null){
commentList.forEach(Comment::setDeleted);
}
}
SocialingRepository.java
public interface SocialingRepository extends JpaRepository<Socialing, Long> {
void deleteByUserIdAndSocialId(Long userId, Long socialId);
void deleteAllByUserId(Long userId);
}
이제 모임 관련 기능을 살펴보겠습니다.
Post 및 PostImage 엔티티 구현
앞서 Social과 Feed가 Post를 부모 클래스로 상속받도록 ERD 설계를 했습니다.
@Inheritance(strategy=InheritanceType.JOINED) 로 조인전략 매핑을 하고
@DiscriminatorColumn(name="DTYPE")과 @DiscriminatorValue를 사용해서 DTYPE 컬럼을 Social과 Feed 각각에 S와 F로 설정해줍니다.
Post가 참조하는 연관관계는 다음과 같습니다.
- Post *-->1 User
- 단방향이므로 Post에서 @ManyToOne을 걸어줍니다.
- Post 1---* PostImage
- 양방향 매핑이라 Post에서는 @OneToMany를, PostImage에서는 @ManyToOne을 걸어줍니다.
- cscade all을 걸어놓긴 했는데, 실무에서는 댓글뿐만 아니라 게시글도 삭제 안할 것 같습니다...!
- Post를 상속받은 Social에서 연관관계 메서드 setImages를 작성했습니다.
- Post 1---* Comment
- 마찬가지로 @OneToMany를 걸어줍니다.
PostImage의 경우, 프론트 팀장님께서 S3가 편해서 사용하시기를 원하셔서 그렇게 진행하기로 했습니다. 서버에서는 그냥 전달받은 주소만 저장하면 되도록 구현했습니다.
PostImage.java
@Getter
@Entity
@Table(name = "IMAGE_FILE")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PostImage {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "file_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post; //연결된 글
@Column(length = 1000)
private String imagePath;
public PostImage(String imagePath) {
this.imagePath = imagePath;
}
//==연관관계 메서드==//
public void setPost(Post post){
this.post = post;
}
}
Post.java
@Getter
@Entity
@Table(name = "POST")
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "post_type")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Slf4j
public abstract class Post extends TimeAuditingEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
@NotNull
private User user; //작성자
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
@BatchSize(size = 100)
protected List<PostImage> images = new ArrayList<>();
@OneToMany(mappedBy = "post")
@BatchSize(size = 100)
private List<Comment> comments = new ArrayList<>();
@NotNull
protected String contents; //내용
@NotNull
@Column(name = "region_code")
protected Integer regionCode; //시군구 5자리
@NotNull
@Column(name = "dong_code")
protected Long dongCode; //읍면동 (자릿수 유동적)
@NotNull
@Column(name = "region_name")
protected String dongName; //행정구역명
@NotNull
private int likes; //좋아요 수
public Post(User user, List<PostImage> images, List<Comment> comments, String contents, int likes) {
this.user = user;
this.images = images;
this.comments = comments;
this.contents = contents;
this.dongCode = user.getDongCode();
this.dongName = user.getDongName();
this.regionCode = user.getRegionCodeFromDongCode();
this.likes = likes;
}
public void updateLikes(int likes){
this.likes = likes;
}
}
Post 및 Comment 클래스는 TimeAuditingEntity를 상속받습니다. 이 클래스에는 createdDate (생성시간) 필드가 있는데요.
제가 구현한 부분은 아니지만, TimeAuditingEntity를 상속받은 엔티티들은 이 클래스의 필드를 컬럼으로 인식하고(@MappedSuperclass의 효과), 자동으로 값이 매핑됩니다(@EntityListeners의 효과).
TimeAuditingEntity.java
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class TimeAuditingEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createDate;
}
Social 엔티티 구현
Social이 참조하는 연관관계는 다음과 같습니다. 연관관계 매핑하는 법은 위에서 설명했으니 이제 넘기겠습니다!
- Social 1-->1 Category
- 중간에 1대1로 요구사항이 바뀌었는데 코드에 반영이 안되어있네요....하하하하하하하핳 고치겠습니다..
- Social 1---* SocialTag *-->1 Tag
- Social 1---* Socialing *-->1 User
- 모임이 삭제되면 모임참여 이력도 자동으로 삭제되도록 cascade all 설정을 했습니다
social.java
@Getter
@Entity
@Table(name = "SOCIAL")
@DiscriminatorValue("S")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Social extends Post {
@OneToMany(mappedBy = "social", cascade = CascadeType.ALL)
private List<Socialing> socialings = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
@OneToMany(mappedBy = "social", cascade = CascadeType.ALL)
private List<SocialTag> socialTags = new ArrayList<>();
@Enumerated(value = EnumType.STRING)
private SocialStatus status;
@NotNull
private String title; //모임 제목
@NotNull
private Integer hits; //조회수
@NotNull
@Column(name = "start_date")
private LocalDateTime startDate; //모임 모집 시작 날짜
@NotNull
@Column(name = "end_date")
private LocalDateTime endDate; //모임 모집 마감 날짜
@NotNull
@Column(name = "current_nums")
private Integer currentNums; //현재 신청한 인원수
@NotNull
@Column(name = "limited_nums")
private Integer limitedNums; //최대 모집 인원수
@Column(name = "contact")
private String contact; //연락 방법
//==수정 메서드==//
public void updateTitle(String title){
this.title = title;
}
public void updateContents(String contents){
this.contents = contents;
}
public void updateStartDate(LocalDateTime startDate){
this.startDate = startDate;
}
public void updateEndDate(LocalDateTime endDate){
this.endDate = endDate;
}
public void updateLimitedNums(Integer limitedNums){
this.limitedNums = limitedNums;
}
public void updateContact(String contact){
this.contact = contact;
}
//==연관관계 메서드==// user, region은 post 메서드 사용
public void addSocialing(Socialing socialing){
socialings.add(socialing);
socialing.setSocial(this);
addCurrentNums();
}
public void setImages(List<PostImage> images){
for(PostImage image : images){
image.setPost(this);
}
this.images = images;
}
//추후 수정될 코드입니다! 연관관계 메서드가 아님. setter는 가급적 지양하는 것이 좋다.
public void setSocialTags(List<SocialTag> socialTags){
this.socialTags = socialTags;
}
//추후 수정될 코드입니다! 연관관계 메서드가 아님. setter는 가급적 지양하는 것이 좋다.
public void setCategory(Category category){
this.category = category;
}
//==소셜 생성 메서드==//
public static Social createSocial(
User user, String title, String contents, String contact,
LocalDateTime startDate, LocalDateTime endDate, Integer limitedNums,
List<PostImage> postImages, Category category, List<Tag> tags, Socialing socialing){
Social social = Social.builder()
.user(user).title(title).contents(contents).contact(contact)
.startDate(startDate).endDate(endDate).limitedNums(limitedNums)
.likes(0).currentNums(0).hits(0) //currentNums는 Socialing 생성 시 따로 설정
.status(SocialStatus.AVAILABLE)
.socialings(new ArrayList<>())
.build();
social.setCategory(category);
social.setImages(postImages);
social.addSocialing(socialing);
//Tag 3개 -> SocialTag 생성 및 세팅
List<SocialTag> socialTags = tags.stream()
.map(tag -> SocialTag.createSocialTag(social, tag))
.collect(toList());
social.setSocialTags(socialTags);
return social;
}
//==비즈니스 로직==//
//소셜링 참여 시 사용
public void addCurrentNums(){
if(currentNums < limitedNums) currentNums++;
if(currentNums == limitedNums) changeStatusToFull();
}
//소셜링 취소, 강퇴 시 사용
public void minusCurrentNums(){
if(currentNums > 0) currentNums--;
}
public void changeStatusToFull(){
status = SocialStatus.FULL;
}
public void changeStatusExpiration() {
this.status = SocialStatus.EXPIRATION;
}
@Builder
public Social(User user, List<PostImage> images, List<Comment> comments, String contents, int likes, Category category,
List<SocialTag> socialTags, List<Socialing> socialings, SocialStatus status, String title, Integer hits,
LocalDateTime startDate, LocalDateTime endDate, Integer currentNums, Integer limitedNums, String contact) {
super(user, images, comments, contents, likes);
this.category = category;
this.socialTags = socialTags;
this.socialings = socialings;
this.status = status;
this.title = title;
this.hits = hits;
this.startDate = startDate;
this.endDate = endDate;
this.currentNums = currentNums;
this.limitedNums = limitedNums;
this.contact = contact;
}
}
Social 또한 User처럼 SocialStatus라는 Enum 필드를 두었습니다.
여기서 제가 프로젝트를 진행할 때 바빠서 실수한 점이 있는데요 ㅠㅠ
양방향 연관관계를 맺어주는 메서드를 연관관계 메서드라고 칭할 수 있는데요, 위 Social 클래스의 addSocialing, setImages 메서드가 연관관계 메서드입니다.
setXXX 메서드로 이름을 짓다가 setter와 헷갈려서 일반 필드의 setter를 써버리는 실수를 했습니다...ㅎ 실무에서는 엔티티가 변경될 위험을 방지하기 위해 setter 사용을 최대한 지양한다고 합니다. 이 부분도 고칠 예정입니다..!!
Socialing 엔티티 구현
Socialing은 User와 Social의 다대다 관계를 매핑해주는 연결용 엔티티입니다.
Socialing이 참조하는 연관관계는 다음과 같습니다.
- Socialing *-->1* User
- Socialing *---1 Social
@Getter
@Entity
@Table(name = "SOCIALING")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Socialing {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "socialing_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "social_id")
private Social social;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
//==생성 메서드==//
public static Socialing createSocialing(User user){
Socialing socialing = new Socialing();
socialing.setUser(user);
return socialing;
}
//==연관관계 메서드==//
public void setUser(User user) {
this.user = user;
}
public void setSocial(Social social){
this.social = social;
}
}
이 코드도... 고칠 필요가 있어보이네요 😂😂😂(할많하않...)
실제 프로젝트 진행할 때에도 아리까리 했던 부분이라... ㅠ 다음엔 이렇게 안하면 되죠 !!
모임 생성/수정
이 부분은 제가 추가로 맡은 기능인데요, 엔티티 연관관계 설정을 다... 해야되는데 시간이 너무 오래걸릴 것 같아서 제가 하기로 했습니다.
급하게 뜯어고쳐서ㅠㅠ... 저렇게 연관관계 메서드들이 말썽인 것입니다 ...^^
SocialController.java
@Tag(name = "social", description = "모임 게시글 API")
@RestController
@RequiredArgsConstructor
public class SocialController {
private final SocialService socialService;
...
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "모임 게시글 생성", description = "contact 제외한 나머지 변수 모두 not null, tags와 images는 1개~3개 가능")
@PostMapping("/social")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK"),
@ApiResponse(responseCode = "401", description = "UNAUTHORIZED"),
@ApiResponse(responseCode = "403", description = "BAD REQUEST"),
@ApiResponse(responseCode = "404", description = "NOT FOUND")
})
public ResponseEntity<?> createSocial(
@AuthenticationPrincipal CustomUserDetails user,
@RequestBody final SocialCreateRequestDTO socialCreateRequestDTO, UriComponentsBuilder uriBuilder){
Social createdSocial = socialService.createSocial(user.getEmail(), socialCreateRequestDTO);
URI location = uriBuilder.path("/social/{id}")
.buildAndExpand(createdSocial.getId()).toUri();
return ResponseEntity.created(location)
.header(HttpHeaders.LOCATION, location.toString())
.body(new ResponseDTO<>(null, "모임 생성 완료"));
}
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "모임 게시글 수정", description = "모든 변수 null available")
@PutMapping("/social/{socialId}")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK"),
@ApiResponse(responseCode = "401", description = "UNAUTHORIZED"),
@ApiResponse(responseCode = "403", description = "BAD REQUEST"),
@ApiResponse(responseCode = "404", description = "NOT FOUND")
})
public ResponseDTO<?> modifySocial(
@AuthenticationPrincipal CustomUserDetails user,
@PathVariable Long socialId,
@RequestBody final SocialModifyRequestDTO socialModifyRequestDTO){
Social modifiedSocial = socialService.modifySocial(user.getEmail(), socialId, socialModifyRequestDTO);
SocialLongDTO socialLongDTO = new SocialLongDTO(modifiedSocial);
return new ResponseDTO<>(socialLongDTO, "모임 수정 완료");
}
}
SocialServiceImpl.java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
@EnableScheduling
public class SocialServiceImpl implements SocialService {
private final UserRepository userRepository;
private final SocialRepository socialRepository;
private final TagRepository tagRePository;
private final PostImageRepository postImageRepository;
private final LikesRepository likesRepository;
//모임 게시글 생성
@Transactional
@Override
public Social createSocial(String email, SocialCreateRequestDTO socialDTO) {
//엔티티 조회
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new EntityNotExistsException(EntityNotExistsExceptionType.NOT_FOUND_USER));
//TagDTO -> String
List<String> stringTags = socialDTO.getTags().stream()
.map(TagDTO::getTag)
.collect(toList());
log.info(stringTags.toString());
List<Tag> tags = tagRePository.findAllByNames(stringTags);
if (tags == null) throw new EntityNotExistsException(EntityNotExistsExceptionType.NOT_FOUND_TAG);
Category category = tags.get(0).getCategory(); //LAZY 로딩
//이미지 경로 String 1~3개 -> PostImage 생성 및 세팅
List<PostImage> postImages = socialDTO.getImages().stream()
.map(dto -> new PostImage(dto.getImagePath()))
.collect(toList());
//생성과 동시에 참여
Socialing socialing = Socialing.createSocialing(user);
//소셜 생성과 동시에 연관관계 설정
Social social = Social.createSocial(
user,
socialDTO.getTitle(),
socialDTO.getContents(),
socialDTO.getContact(),
socialDTO.getStartDate(),
socialDTO.getEndDate(),
socialDTO.getLimitedNums(),
postImages,
category,
tags,
socialing
);
return socialRepository.save(social);
}
//모임 게시글 삭제
@Transactional
@Override
public void deleteSocial(Long postId) {
socialRepository.deleteById(postId);
}
//모임 게시글 수정
@Transactional
@Override
public Social modifySocial(String email, Long socialId, SocialModifyRequestDTO socialDTO) {
//엔티티 조회
Social social = socialRepository.findById(socialId)
.orElseThrow(() -> new EntityNotExistsException(EntityNotExistsExceptionType.NOT_FOUND_SOCIAL));
// null일 경우에는 수정X
if (socialDTO.getTitle() != null) social.updateTitle(socialDTO.getTitle());
if (socialDTO.getContents() != null) social.updateContents(socialDTO.getContents());
if (socialDTO.getStartDate() != null) social.updateStartDate(socialDTO.getStartDate());
if (socialDTO.getEndDate() != null) social.updateEndDate(socialDTO.getEndDate());
if (socialDTO.getLimitedNums() != null) social.updateLimitedNums(socialDTO.getLimitedNums());
if (socialDTO.getContact() != null) social.updateContact(socialDTO.getContact());
//이미지
if (!CollectionUtils.isEmpty(socialDTO.getImages())) {
//DB에서 PostImage 삭제
postImageRepository.deleteAllByPostId(social.getId());
//갈아끼움
List<PostImage> postImages = socialDTO.getImages().stream()
.map(dto -> new PostImage(dto.getImagePath()))
.collect(toList());
social.setImages(postImages);
}
return social;
}
}
모임 신청/신청취소/모임원 강퇴
이 부분은 원래 제가 맡은 기능이라 API 설계 부터 살펴보겠습니다.
- /socialing/{socialId} 모임 참가
- /socialing/{socialId} 모임 참가 취소
- /socialing/{socialId}/{kickedUserId} 모임원 강퇴
SocialingController.java
@Tag(name = "socialing", description = "유저-모임 API")
@RestController
@RequiredArgsConstructor
public class SocialingController {
private final UserService userService;
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "모임 참가")
@PostMapping("/socialing/{socialId}")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "CREATED"),
@ApiResponse(responseCode = "401", description = "UNAUTHORIZED"),
@ApiResponse(responseCode = "403", description = "BAD REQUEST"),
@ApiResponse(responseCode = "404", description = "NOT FOUND")
})
public ResponseDTO<?> participateSocial(@AuthenticationPrincipal CustomUserDetails user, @PathVariable Long socialId) {
userService.participateSocial(user.getUserId(), socialId);
return ResponseDTO.builder().success(true).message("모임 신청 처리되었습니다.").build();
}
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "모임 참가 취소")
@DeleteMapping("/socialing/{socialId}")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "CREATED"),
@ApiResponse(responseCode = "401", description = "UNAUTHORIZED"),
@ApiResponse(responseCode = "403", description = "BAD REQUEST"),
@ApiResponse(responseCode = "404", description = "NOT FOUND")
})
public ResponseDTO<?> cancelParticipationOfSocial(@AuthenticationPrincipal CustomUserDetails user, @PathVariable Long socialId) {
userService.cancelSocialParticipation(user.getUserId(), socialId);
return ResponseDTO.builder().success(true).message("모임 신청이 취소되었습니다.").build();
}
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "모임 강퇴")
@PostMapping("/socialing/{socialId}/{kickedUserId}")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "CREATED"),
@ApiResponse(responseCode = "401", description = "UNAUTHORIZED"),
@ApiResponse(responseCode = "403", description = "BAD REQUEST"),
@ApiResponse(responseCode = "404", description = "NOT FOUND")
})
public ResponseDTO<?> kickOutSocialUser(@AuthenticationPrincipal CustomUserDetails user, @PathVariable Long socialId, @PathVariable Long kickedUserId) {
userService.kickOutUserFromSocial(user.getUserId(), socialId, kickedUserId);
return ResponseDTO.builder().success(true).message("해당 유저가 강퇴처리되었습니다.").build();
}
}
음 @APiResponse 에 201을 왜 넣어놨을까요...😅 200으로 고치는게 나아보이네요
UserServiceImpl.java
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final SocialRepository socialRepository;
private final SocialingRepository socilaingRepository;
private final CommentRepository commentRepository;
private final SnsAPIService snsAPIService;
private final PasswordEncoder passwordEncoder;
...
@Transactional
@Override
public void participateSocial(Long userId, Long socialId) {
//엔티티 조회
User findUser = userRepository.findReadOnlyById(userId)
.orElseThrow(() -> new EntityNotExistsException(EntityNotExistsExceptionType.NOT_FOUND_USER));
Social findSocial = socialRepository.findById(socialId)
.orElseThrow(() -> new EntityNotExistsException(EntityNotExistsExceptionType.NOT_FOUND_SOCIAL));
//모임이 꽉찬 상태면 x
if(findSocial.getStatus() == SocialStatus.FULL) throw new SocialInvalidInputException(SocialInvalidInputExceptionType.FULL_STATUS);
//중복 신청이면 x
if(findSocial.getUser().getId().equals(findUser.getId())) throw new SocialInvalidInputException(SocialInvalidInputExceptionType.ALREADY_APPLIED);
//신청 처리
findSocial.addCurrentNums(); // 참여인원 +1
Socialing socialing = Socialing.createSocialing(findUser);
findSocial.addSocialing(socialing);
socilaingRepository.save(socialing);
}
@Transactional
@Override
public void cancelSocialParticipation(Long userId, Long socialId) {
User findUser = userRepository.findReadOnlyById(userId)
.orElseThrow(() -> new EntityNotExistsException(EntityNotExistsExceptionType.NOT_FOUND_USER));
Social findSocial = socialRepository.findById(socialId)
.orElseThrow(() -> new EntityNotExistsException(EntityNotExistsExceptionType.NOT_FOUND_SOCIAL));
//모임 취소처리
findSocial.minusCurrentNums(); //참여인원-1
socilaingRepository.deleteByUserIdAndSocialId(findUser.getId(), socialId);
}
@Transactional
@Override
public void kickOutUserFromSocial(Long userId, Long socialId, Long droppedUserId) {
//엔티티 조회
Social social = socialRepository.findById(socialId)
.orElseThrow(() -> new EntityNotExistsException(EntityNotExistsExceptionType.NOT_FOUND_SOCIAL));
User socialOwner = social.getUser(); //모임장
//모임장인지 확인
if(!userId.equals(socialOwner.getId())) throw new ForbiddenException(ForbiddenExceptionType.USER_UN_AUTHORIZED);
//강퇴 처리
socilaingRepository.deleteByUserIdAndSocialId(droppedUserId, socialId);
social.minusCurrentNums();//강퇴하고 참여인원-1
}
}
SocialingRepository.java
public interface SocialingRepository extends JpaRepository<Socialing, Long> {
void deleteByUserIdAndSocialId(Long userId, Long socialId);
void deleteAllByUserId(Long userId);
}
'회고록' 카테고리의 다른 글
[Numble 챌린지 개발일지] 5주차 (1) 로그아웃 리디렉션 해결 (0) | 2022.12.15 |
---|---|
[Numble 챌린지 개발일지] 4주차 (2) JWT 사용해서 로그인 구현 (0) | 2022.12.15 |
[Numble 챌린지 개발일지] 3주차 REST API에 Swagger 입히기 (0) | 2022.11.09 |
[Numble 챌린지 개발일지] 2주차 협업방식 정하기, ERD 설계 (0) | 2022.11.06 |
[Numble 챌린지 개발일지] 1주차 챌린지 시작 및 컨셉 기획 (0) | 2022.10.24 |