깃허브에서 이슈를 만들고 브랜치를 만들 수 있다.
아래와 같이 이슈를 만들어주었고,
오른쪽에 Development 에서 Create a branch를 통해 브랜치를 만들 수 있다.

브랜치 이름은 feature/1 로 설정해주었다.
이 예시에서는 업무종류/이슈번호로 브랜치 이름을 정했다. (이 업무는 feature이고 이슈 번호가 1번이기 때문)

아래 명령어를 복사해주고,

이제 새로운 브랜치에서 작업을 해줄 것이다.
이때, 주의할 점!
새 브랜치가 뻗어나갈 브랜치에서 브랜치를 만들어줘야 한다!!
이 예제에서는 main브랜치에서 브랜치를 파도록 하겠다.
먼저 main 브랜치로 이동하고,
main 브랜치에서 먼저 pull을 받아 main 브랜치의 변경 사항을 가져온 후 새 브랜치를 파야 한다.


아까 복사한 명령대로,
이제 새로운 브랜치를 만들어주자.

feature/1 브랜치가 성공적으로 생성된 것을 확인할 수 있다.

이제 회원가입 API를 만들겠다.
8주차 처음부터 틀이랑 이런 것을 새로 만들어야 하지만,,
다시 처음부터 만들기 귀찮기 때문에.. 8주차 브랜치를 머지 받아오겠다..!

내가 6주차 미션에서 작성한 API 명세서를 참고해서 다음과 같이 구현했다.
public class UserRequestDTO {
@Getter
public static class UserJoinReq {
String name;
Integer gender;
Integer birth_year;
Integer birth_month;
Integer birth_day;
String address;
String likeFoods;
}
}
public class UserResponseDTO {
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class JoinResultRes {
Long memberId;
LocalDateTime createdAt;
}
}
public class UserConverter {
public static UserResponseDTO.JoinResultRes toJoinResultRes(User user) {
return UserResponseDTO.JoinResultRes.builder()
.memberId(user.getId())
.createdAt(LocalDateTime.now())
.build();
}
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserRestController {
private final UserCommandService userCommandService;
@PostMapping("/join")
public ApiResponse<UserResponseDTO.JoinResultRes> join(@RequestBody @Valid UserRequestDTO.UserJoinReq request) {
return null;
}
}
다음과 같이 UserService 패키지와 그 아래 서비스 파일들을 만들어주었다.

Spring Data JPA를 사용할 것이므로 인터페이스로 만들어준다.
public interface UserRepository extends JpaRepository<User, Long> {
}
@Service
@RequiredArgsConstructor
public class UserCommandServiceImpl implements UserCommandService {
private final UserRepository userRepository;
@Override
public User joinUser(UserRequestDTO.UserJoinReq request) {
return null;
}
}
프로젝트마다 다르지만, User 객체를 만드는 작업(클라이언트가 준 DTO를 Entity로 만드는 작업)은 서비스 단에서 할 수도 있고 Converter에서 할 수도 있다.
이 예제에서는 User 객체를 만드는 작업은 converter에서 하겠다.
public class UserConverter {
public static UserResponseDTO.JoinResultRes toJoinResultRes(User user) {
return UserResponseDTO.JoinResultRes.builder()
.memberId(user.getId())
.createdAt(LocalDateTime.now())
.build();
}
public static User toUser(UserRequestDTO.UserJoinReq request){
Gender gender = null;
switch (request.getGender()){
case 1:
gender = Gender.MALE;
break;
case 2:
gender = Gender.FEMALE;
break;
case 3:
gender = Gender.NONE;
break;
}
return User.builder()
.address(request.getAddress())
.gender(gender)
.name(request.getName())
.userPreferList(new ArrayList<>())
.build();
}
}
나는 워크북을 참고해서 위와 같이 만들었고,
리스트의 경우 new ArrayList<>() 로 초기화해주면 된다고 한다.
그리고 원래 이메일을 소셜 로그인에서 처리하고 나머지 정보를 기입받는 것이 맞는 순서이지만,
지금은 소셜 로그인 없이 하는 중이라 User에서 이메일은 nullable로 바꾸고 진행하겠다.

application.yml에서 auto:create로 바꿔주고 실행해서 테이블을 다시 만들자.

실행 후 다시 update로 바꿔주었다.
@Service
@RequiredArgsConstructor
public class UserCommandServiceImpl implements UserCommandService {
private final UserRepository userRepository;
@Override
public User joinUser(UserRequestDTO.UserJoinReq request) {
User newUser = UserConverter.toUser(request);
return null;
}
}
이제 유저가 선호하는 카테고리를 처리해야 하는데,
아까 UserJoinReq에서 선호하는 음식들을 나는 String으로 한번에 받아오는 것을 생각했는데
워크북에서는 음식 카테고리의 id 값들을 리스트로 받아오는 것으로 진행해본다고 하셔서 List<Long> likeFoods;으로 변경해보고자 한다.
import java.util.List;
public class UserRequestDTO {
@Getter
public static class UserJoinReq {
String name;
Integer gender;
Integer birth_year;
Integer birth_month;
Integer birth_day;
String address;
List<Long> likeFoods;
}
}
DB에서 음식 카테고리들을 만들어준다.

public interface FoodRepository extends JpaRepository<Food, Long> {
}
@Service
@RequiredArgsConstructor
public class UserCommandServiceImpl implements UserCommandService {
private final UserRepository userRepository;
private final FoodRepository foodRepository;
@Override
public User joinUser(UserRequestDTO.UserJoinReq request) {
User newUser = UserConverter.toUser(request);
List<Food> foodList = request.getLikeFoods().stream()
.map(category -> {
return foodRepository.findById(category).orElseThrow(() -> new FoodHandler(ErrorStatus.FOOD_CATEGORY_NOT_FOUND));
}).collect(Collectors.toList());
List<FoodOfUser> userPreferList = UserPreferConverter.toUserPreferList(foodList);
return null;
}
}
위와 같이 작성했고, 이를 위해
package umc.spring.apiPayload.exception.handler;
import umc.spring.apiPayload.code.BaseErrorCode;
import umc.spring.apiPayload.exception.GeneralException;
public class FoodHandler extends GeneralException {
public FoodHandler(BaseErrorCode errorCode) {
super(errorCode);
}
}
FOOD_CATEGORY_NOT_FOUND 상태를 추가하고,
public class UserPreferConverter {
public static List<FoodOfUser> toUserPreferList(List<Food> foodCategoryList){
return foodCategoryList.stream()
.map(foodCategory ->
FoodOfUser.builder()
.food(foodCategory)
.build()
).collect(Collectors.toList());
}
}
이제 서비스에서는 연관관계를 설정하고,
양방향 매핑이 된 경우 연관 관계 편의 메서들을 이용해 양방향 매핑을 해줘야 한다.
참고로 워크북을 작성하신 분의 연관 관계 편의 메서드는 아래와 같은 모양을 가진다고 한다.
FoodOfUser(유저 별 선호하는 음식) 엔티티에 아래와 같은 연관 관계 편의 메서드를 작성해주었다.

@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class FoodOfUser extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "food_id")
private Food food;
public void setUser(User user){
if(this.user != null)
user.getUserPreferList().remove(this);
this.user = user;
user.getUserPreferList().add(this);
}
public void setFood(Food foodCategory){
this.food = foodCategory;
}
}
완성된 Service 코드는 다음과 같다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserCommandServiceImpl implements UserCommandService {
private final UserRepository userRepository;
private final FoodRepository foodRepository;
@Override
public User joinUser(UserRequestDTO.UserJoinReq request) {
User newUser = UserConverter.toUser(request);
List<Food> foodList = request.getLikeFoods().stream()
.map(category -> {
return foodRepository.findById(category).orElseThrow(() -> new FoodHandler(ErrorStatus.FOOD_CATEGORY_NOT_FOUND));
}).collect(Collectors.toList());
List<FoodOfUser> userPreferList = UserPreferConverter.toUserPreferList(foodList);
userPreferList.forEach(foodOfUser -> {foodOfUser.setUser(newUser);});
return userRepository.save(newUser);
}
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserRestController {
private final UserCommandService userCommandService;
@PostMapping("/join")
public ApiResponse<UserResponseDTO.JoinResultRes> join(@RequestBody @Valid UserRequestDTO.UserJoinReq request) {
User user = userCommandService.joinUser(request);
return ApiResponse.onSuccess(UserConverter.toJoinResultRes(user));
}
}
테스트를 해야 하는데, Postman에서 테스트를 할 수도 있지만
Swagger라는 매우 편리하고 좋은 툴이 있다.
build.gradle에 아래와 같이 의존성을 추가해주고 코끼리 모양을 눌러서 빌드해준다.
implementation 'org.springdoc:springdoc-openapi-ui:1.6.15'
implementation 'io.springfox:springfox-swagger2:2.9.2'
implementation 'io.springfox:springfox-swagger-ui:2.9.2'

이후 config 패키지를 만들어주자.

다음과 같이 SwaggerConfig 파일을 세팅해주고
package umc.spring.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI UMCstudyAPI() {
Info info = new Info()
.title("UMC Server WorkBook API")
.description("UMC Server WorkBook API 명세서")
.version("1.0.0");
String jwtSchemeName = "JWT TOKEN";
// API 요청헤더에 인증정보 포함
SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName);
// SecuritySchemes 등록
Components components = new Components()
.addSecuritySchemes(jwtSchemeName, new SecurityScheme()
.name(jwtSchemeName)
.type(SecurityScheme.Type.HTTP) // HTTP 방식
.scheme("bearer")
.bearerFormat("JWT"));
return new OpenAPI()
.addServersItem(new Server().url("/"))
.info(info)
.addSecurityItem(securityRequirement)
.components(components);
}
}
프로젝트를 실행시킨 후 http://localhost:8080/swagger-ui/index.html#/ 로 접속해보자.
URL들이 바로 뜨게 되는데,

방금 만든 API를 테스트해보자.

-->
Connection is read-only. Queries leading to data modification are not allowed
이런 오류가 떴는데.. 구글링해보니 UserCommandServiceImpl에서 Transactional(readOnly = true) 로 설정해서 문제가 생긴 것 같다.
그래서 false로 바꿔주고 다시 실행해보니 정상적으로 작동하게 되었다!

DB에도 잘 들어감!

그런데 지난번에 status 속성에 대해 default 설정을 해줬는데 null로 저장되어 있다. -> 이는 JPA가 save로 DB에 저장할 때, null인 값에 대해 그냥 null을 insert해버려서 생기는 문제라고 한다.

이를 막기 위해 User Entity를 아래와 같이 수정해주겠다.
package umc.spring.domain;
import lombok.*;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import umc.spring.domain.common.BaseEntity;
import umc.spring.domain.enums.Gender;
import umc.spring.domain.enums.SocialType;
import umc.spring.domain.enums.UserStatus;
import umc.spring.domain.mapping.FoodOfUser;
import umc.spring.domain.mapping.UserMission;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@DynamicUpdate
@DynamicInsert
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class User extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 15)
private String name;
@Enumerated(EnumType.STRING)
@Column(columnDefinition = "VARCHAR(10)")
private Gender gender;
private Integer birthYear;
private Integer birthMonth;
private Integer birthDay;
@Column(nullable = true, length = 100)
private String address;
@ColumnDefault("0")
private Integer point;
@Enumerated(EnumType.STRING)
@Column(columnDefinition = "VARCHAR(15) DEFAULT 'ACTIVE'")
private UserStatus status;
// @Column(nullable = false, length = 50)
private String email;
@Enumerated(EnumType.STRING)
private SocialType socialType;
private boolean certification;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<FoodOfUser> userPreferList = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<Review> reviewList = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<UserMission> userMissionList = new ArrayList<>();
}
@DynamicUpdate와 @DynamicInsert는 insert와 update 시 null 인 경우는 그냥 쿼리를 보내지 않도록 해준다.
그리고 point 속성에 @ColumnDefault("0")를 추가해주었다.
다시 실행시켜서 테스트해보면
아까와 똑같이 요청을 보내주고,

응답도 잘 도착하는 것을 확인할 수 있다.

그리고 DB에는 Default 값도 잘 들어가는 것을 확인할 수 있다.

service함수를 보면 (UserCommandServiceImpl)
서비스 단에서 validation을 하고 있는 것을 확인할 수 있다.

이를 @를 활용해 더 좋은 방식으로 리팩토링할 수 있다고 한다.
아래 사진과 같이 validation 패키지와 하위 패키지들을 만들어주자.

이제 어노테이션을 만들어서 food category가 DB에 존재하는지 검증해보겠다.
build.gradle에 새로운 의존성을 추가해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'

아래와 같이 파일 구조를 만들어주고,

ExistCategories를 다음과 같이 입력한다.
package umc.spring.validation.annotation;
import umc.spring.validation.validator.CategoriesExistValidator;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = CategoriesExistValidator.class)
@Target( { ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
public @interface ExistCategories {
String message() default "해당하는 카테고리가 존재하지 않습니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Constraint의 파라미터로 validatedBy가 있고 CategoriesExistValidator.class를 지정하고 있다. -> CategoriesExistValidator라는 클래스를 통해 @ExistCategories가 붙은 대상을 검증한다.
CategoriesExistValidator
package umc.spring.validation.validator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import umc.spring.apiPayload.code.status.ErrorStatus;
import umc.spring.repository.FoodRepository;
import umc.spring.validation.annotation.ExistCategories;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.List;
@Component
@RequiredArgsConstructor
public class CategoriesExistValidator implements ConstraintValidator<ExistCategories, List<Long>> {
private final FoodRepository foodRepository;
@Override
public void initialize(ExistCategories constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
}
@Override
public boolean isValid(List<Long> values, ConstraintValidatorContext context) {
boolean isValid = values.stream()
.allMatch(value -> foodRepository.existsById(value));
if (!isValid) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(ErrorStatus.FOOD_CATEGORY_NOT_FOUND.toString()).addConstraintViolation();
}
return isValid;
}
}
설명

-->
ConstraintValidator 인터페이스에 대한 구체화 클래스로 만들고,
이때 ExistCategories 어노테이션에 대한 로직을 담을 것이고 검증 대상이 List임을 명시한다.
인터페이스가 요구하는 메서드를 다 오버라이딩하고,
isValid 메서드만 원하는 형태로 바꿔주면 된다.
위 코드에서는 isValid함수에서 검증 대상인 List 의 값을 가진 카테고리가 모두 데이터베이스에 있는 지를 판단하고, 없을 경우 false를 반환한다.
--> 이를 스웨거에서 테스트해보면,

위 사진과 같이 Http status : 400이 오고 error임에도 result에 값이 들어가게 나와야 한다.
-> 이때 request body 중 어디를 잘못했는지를 알려주고 있다.
-> 프론트 개발자를 배려해서 응답을 결정한 것이다.
위 응답이 어떻게 나오게 되었을까?
먼저 implements한 ConstraintValidator는 isValid 메서드의 반환 값을 확인하고 검증이 되었는지 실패했는지를 알려준다.
-> 만약 isValid의 리턴 값이 false인 경우 : ConstraintViolationException을 발생시킨다.

이때 UserRequestDTO 안의 UserJoinReq의 likeFoods에다가 @ExistCategories 어노테이션을 달아두어서,

컨트롤러가 RequestBody를 받아오는 과정에서 @ExistCategories가 붙어 있는 DTO로 인해

CategoriesExistValidator의 isValid 코드가 실행되게 된다.

이 isValid 메서드에서 false를 리턴하면 ConstraintViolationException 를 발생시키는데

@Valid 어노테이션이 존재하므로 @ExistCategories에서 발생한 예외가 바로 전달이 되지 않고,
@Valid 어노테이션이 MethodArgumentNotValidException를 발생시키게 된다.
따라서

advice에서 MethodArgumentNotValidException를 감지하게 된다.
아래 사진처럼 Request Body가 아닌 PathVariable 등등 다른 값에 대해서도 어노테이션을 붙일 수 있다고 한다.
-> 이 경우에는 @Valid를 거치지 않아서, 곧바로 ConstraintViolationException 가 전달된다.

UserJoinReq에서 나머지 필드에 대한 검증 조건들도 붙여주고
package umc.spring.web.dto;
import lombok.Getter;
import umc.spring.validation.annotation.ExistCategories;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.util.List;
public class UserRequestDTO {
@Getter
public static class UserJoinReq {
@NotBlank
String name;
@NotNull
Integer gender;
@NotNull
Integer birth_year;
@NotNull
Integer birth_month;
@NotNull
Integer birth_day;
@Size(min = 5, max = 12)
String address;
@ExistCategories
List<Long> likeFoods;
}
}
스웨거에서 위의 조건을 여러 개 어겨보면,

아래와 같이 여러 사항에 대한 응답이 오는 것을 확인할 수 있다.
