챌린지를 진행하는 중에는 일정이 너무 빠듯해서 이때 부터는 개발 일지를 제때 올리지 못했는데요, 이제 쌓아둔 노트를 하나둘씩 정리해서 포스팅 해보려 합니다 😏
제가 맡은 기능은 다음과 같습니다.
- 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으로 저장합니다.
![](https://blog.kakaocdn.net/dn/ztvqT/btr21BJw6sh/IB7XHRsfHVk6PoBUhNkQj0/img.png)
서울 기준으로는 다음과 같이 행정구역 코드의 앞 5자리가 구로 나뉩니다.
![](https://blog.kakaocdn.net/dn/cJ8stn/btr2Px3t5HB/5mQRO5u5QuqlxNdaKw7kSK/img.png)
다른 지역은 이렇게 시.군.구로도 나뉩니다.
![](https://blog.kakaocdn.net/dn/bm9hO7/btr2YaMYLtn/HwDAFx1JGs4uJ1YYAJk0g0/img.png)
예를 들어 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 타입으로 써야해요.
![](https://blog.kakaocdn.net/dn/kNPdd/btr2ZBDO6r5/LQQNciSMKxn3X8GkkQhyNK/img.png)
회원 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편 강의를 학습했을 때에는 이렇게 배웠습니다.
![](https://blog.kakaocdn.net/dn/VwOJY/btr1333jSH0/BHcL0L37kKYK8mlQCKumm1/img.png)
그래서 게시글 수정 시에는 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 신규 자원등록의 특징을 짚고 넘어갑시다.
![](https://blog.kakaocdn.net/dn/bALw7l/btr24Lk0450/l5SS560JalbH3X5FNZauV0/img.png)
회원가입 같은 경우, 상태코드는 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 이거만 쓰면 된다는 거죠! 😲 고쳐야 할 코드가 늘었네용 ... ㅎ
![](https://blog.kakaocdn.net/dn/bXdHlt/btr20AR9Qo6/PXHxBTY3F43vN2vC3TqyV1/img.png)
회원 탈퇴
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 |