[인프런 워밍업 스터디 클럽] 미니프로젝트 - step1

[인프런 워밍업 스터디 클럽] 미니프로젝트 - step1

미니 프로젝트 - 개발 일지

드디어 미니프로젝트 시작이다.

프로젝트 세팅

  • 언어: JDK21

  • 프레임워크: Spring Boot 3.2.3, Spring Data JPA

  • 라이브러리: 롬복

  • DB: mysql

  • 테스트: junit5


요구사항

image


환경 설정

1. profile 분리

먼저 나는 프로필을 분리하기로 하였다. 개발환경 profile과 운영환경 profile 그리고 공통적인 부분을 묶어두었다. 또한 DB의 정보는 민감한 정보이므로 환경변수에 등록해두었다.

application.yml

spring:
  profiles:
    group:
      dev: "dev, common"
      prod: "prod, common"
    active: dev

---
spring:
  config:
    activate:
      on-profile: dev
  datasource:
    url: "jdbc:mysql://${DB_DEV_HOST}/${DB_DEV_SCHEMA}"
    username: ${DB_DEV_USERNAME}
    password: ${DB_DEV_PASSWORD}
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        show_sql: true
        format_sql: true
    open-in-view: false
logging:
  level:
    sql: trace
---
spring:
  config:
    activate:
      on-profile: prod
---
spring:
  config:
    activate:
      on-profile: common

2. Auditing 기능 개발

다음으로 나는 spring data jpa에서 제공해주는 Auditing 기능을 먼저 이용하려고 한다. 기본 엔티티를 만들기 전에 추상클래스로 공통적인 속성들을 묶어서 만들기로 하였다.

BaseDateTimeEntity.java

@Getter
@MappedSuperclass
@EntityListeners(value = AuditingEntityListener.class)
public abstract class BaseDateTimeEntity {

    @CreatedDate
    @Comment("생성 날짜")
    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
    @Column(nullable = false, updatable = false)
    private LocalDate createdAt;

    @LastModifiedDate
    @Comment("최종 수정 날짜")
    @Column(nullable = false)
    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
    private LocalDate updatedAt;
}

BaseEntity.java

@Getter
@MappedSuperclass
@EntityListeners(value = AuditingEntityListener.class)
public class BaseEntity extends BaseDateTimeEntity {

    @CreatedBy
    @Comment("생성한 직원")
    @Column(nullable = false, updatable = false)
    private String createdBy;

    @LastModifiedBy
    @Comment("최종 수정한 직원")
    @Column(nullable = false)
    private String updatedBy;
}

먼저 위의 BaseDateTimeEntity와 같이 생성 날짜와 최종 수정 날짜를 정의하였고, BaseEntity에서는 추가적으로 생성한 직원 최종 수정한 직원 부분까지 더했다. 그 이유는 요구사항에는 BaseEntity 부분이 필요가 없겠지만 나중에 추후 확장성을 위해 사용하기로 하였다. 그리고 이를 위해 Auditing 설정 파일을 작성해주었다.

AuditConfig.java

@Configuration
@EnableJpaAuditing
public class AuditConfig {

    @Bean
    public AuditorAware<String> auditorAwareProvider() {
        return new AuditorAwareImpl();
    }
}

AuditorAwareImpl.java

public class AuditorAwareImpl implements AuditorAware<String> {
    @Override
    public Optional<String> getCurrentAuditor() {
        return Optional.of("tester");
    }
}

공통 예외 부분

우리가 예외를 처리하다보면 커스텀하게 예외를 던져야 할 경우가 생긴다. 그리고 예외가 던져졌을 때 에러 로그가 아니라 그에 대한 커스텀 응답을 받고 싶은 경우도 있을 것이다. 이에 따라 일련의 과정을 정리해본다.

먼저 예외에 마다 특정 예외에 코드가 있다고 생각을 하였다. 그에 따른 인터페이스를 이와 같이 정의하였다.

public interface ExceptionCode {
    HttpStatus getHttpStatus();

    String getCode();

    String getMessage();
}

그리고 해당 인터페이스를 구현한 GlobalExceptionCode enum 클래스를 개발한다.

@Getter
@RequiredArgsConstructor
public enum GlobalExceptionCode implements ExceptionCode {

    INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "G-001", "Invalid Input Value"),
    METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "G-002", "Invalid Http Request Method"),
    ENTITY_NOT_FOUND(HttpStatus.NOT_FOUND, "G-003", "Resource Not Found"),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "F-001", "Server Error!");

    private final HttpStatus httpStatus;

    private final String code;

    private final String message;
}

이제 커스텀 예외 응답 클래스를 개발하자. 이번엔 조금 디자인 패턴 중 정적 팩터리 메서드 패턴을 적용해보았다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ExceptionResponse {

    private String message;

    private HttpStatus status;

    private String code;

    private List<ValidationException> errors;

    private LocalDateTime timestamp;

    private ExceptionResponse(final ExceptionCode exceptionCode) {
        this.message = exceptionCode.getMessage();
        this.status = exceptionCode.getHttpStatus();
        this.code = exceptionCode.getCode();
        this.timestamp = LocalDateTime.now();
        this.errors = new ArrayList<>();
    }

    private ExceptionResponse(final ExceptionCode errorCode, final String message) {
        this.message = message;
        this.status = errorCode.getHttpStatus();
        this.code = errorCode.getCode();
        this.timestamp = LocalDateTime.now();
        this.errors = new ArrayList<>();
    }

    private ExceptionResponse(final ExceptionCode errorCode, final List<ValidationException> errors) {
        this.message = errorCode.getMessage();
        this.status = errorCode.getHttpStatus();
        this.code = errorCode.getCode();
        this.timestamp = LocalDateTime.now();
        this.errors = errors;
    }

    public static ExceptionResponse of(final ExceptionCode errorCode) {
        return new ExceptionResponse(errorCode);
    }
    public static ExceptionResponse of(final ExceptionCode errorCode, final String message) {
        return new ExceptionResponse(errorCode, message);
    }

    public static ExceptionResponse of(final ExceptionCode code, final BindingResult bindingResult) {
        return new ExceptionResponse(code, ValidationException.of(bindingResult));
    }
    public static ExceptionResponse of(final ExceptionCode errorCode, final List<ValidationException> errors) {
        return new ExceptionResponse(errorCode, errors);
    }
}

다음으로 우리가 정의하지 않는 validation Exception부분도 처리해줄 필요가 있었다. 그래서 아래와 같이 커스텀하게 구성을 해보았다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ValidationException {

    private String field;
    private String value;
    private String reason;

    private ValidationException(String field, String value, String reason) {
        this.field = field;
        this.value = value;
        this.reason = reason;
    }

    public static List<ValidationException> of(final String field, final String value, final String reason) {
        List<ValidationException> validationExceptions = new ArrayList<>();
        validationExceptions.add(new ValidationException(field, value, reason));
        return validationExceptions;
    }

    public static List<ValidationException> of(final BindingResult bindingResult) {
        final List<FieldError> validationExceptions = bindingResult.getFieldErrors();
        return validationExceptions.stream()
                .map(error -> new ValidationException(
                        error.getField(),
                        error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(),
                        error.getDefaultMessage()))
                .collect(Collectors.toList());
    }
}

마지막을 ExcpetionHandler를 통해 예외처리를 해두었다. 여기서 이제 커스텀 예외가 생길때 예외 클래스를 생성 후 RuntimeException을 상속받은 후에 해당 핸들러 클래스에 적용해두면 된다.

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * Java Bean Validation 예외 핸들링
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    protected ResponseEntity<ExceptionResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        log.error("handle MethodArgumentNotValidException");
        return new ResponseEntity<>(ExceptionResponse.of(INVALID_INPUT_VALUE, e.getBindingResult()),
                INVALID_INPUT_VALUE.getHttpStatus());
    }

    /**
     * EntityNotFound 예외 핸들링
     */
    @ExceptionHandler(EntityNotFoundException.class)
    protected ResponseEntity<ExceptionResponse> handleEntityNotFoundException(EntityNotFoundException e) {
        log.error("handle EntityNotFoundException");
        return new ResponseEntity<>(
                ExceptionResponse.of(ENTITY_NOT_FOUND, e.getMessage()),
                ENTITY_NOT_FOUND.getHttpStatus());
    }

    /**
     * 유효하지 않은 클라이언트의 요청 값 예외 처리
     */
    @ExceptionHandler(IllegalArgumentException.class)
    protected ResponseEntity<ExceptionResponse> handleIllegalArgumentException(IllegalArgumentException e) {
        log.error("handle IllegalArgumentException");
        return new ResponseEntity<>(
                ExceptionResponse.of(INVALID_INPUT_VALUE, e.getMessage()),
                INVALID_INPUT_VALUE.getHttpStatus()
        );
    }

    @ExceptionHandler(TeamAlreadyExistsException.class)
    protected ResponseEntity<ExceptionResponse> handleTeamAlreadyExistsException(TeamAlreadyExistsException e) {
        log.error("handle TeamAlreadyExistsException");

        return new ResponseEntity<>(
                ExceptionResponse.of(INVALID_INPUT_VALUE, e.getMessage()),
                INVALID_INPUT_VALUE.getHttpStatus()
        );
    }

    /**
     * 잘못된 HTTP Method 요청 예외 처리
     */
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    protected ResponseEntity<ExceptionResponse> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
        log.error("handle HttpRequestMethodNotSupportedException");
        return new ResponseEntity<>(
                ExceptionResponse.of(METHOD_NOT_ALLOWED),
                METHOD_NOT_ALLOWED.getHttpStatus()
        );
    }

    /**
     * 잘못된 타입 변환 예외 처리
     */
    @ExceptionHandler(BindException.class)
    protected ResponseEntity<ExceptionResponse> handleBindException(BindException e) {
        log.error("handle BindException");
        return new ResponseEntity<>(
                ExceptionResponse.of(INVALID_INPUT_VALUE, e.getBindingResult()),
                INVALID_INPUT_VALUE.getHttpStatus()
        );
    }

    /**
     * 모든 예외를 처리
     * 웬만해서 여기까지 오면 안됨
     */
    @ExceptionHandler(Exception.class)
    protected ResponseEntity<ExceptionResponse> handleException(Exception e) {
        log.error("handle Exception", e);
        return new ResponseEntity<>(
                ExceptionResponse.of(INTERNAL_SERVER_ERROR),
                INTERNAL_SERVER_ERROR.getHttpStatus()
        );
    }
}

주요기능


팀 등록 기능

🤔 고려해볼 점

1. 팀을 등록할 수 있어야 한다.

2. 팀 이름이 null이거나 공란으로 요청이 갈 경우 예외처리

3. 만약 이미 존재하는 팀이라면 예외를 던진다.

요청 DTO

  • spring boot starter validation을 통하여 요청 필드에 대하여 validation 처리

  • DTO를 엔티티화 하는 로직부분을 해당 DTO안에 구현

public record RegisterTeamRequestDto(
        @NotBlank(message = "이름은 공란일 수 없습니다.")
        @NotNull(message = "이름은 null일 수 없습니다.")
        String name
) {

    public Team toEntity() {
        return Team.builder()
                .name(name)
                .build();
    }
}

서비스 레이어

  • 별 다른 것은 없고 insert 쿼리가 날려주는 작업으로 메서드에 트랜잭션 어노테이션을 붙여주었다.

  • private 메서드로 해당 팀이 이미 존재하는지 확인하는 validation을 추가하였다.

     

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TeamService {

    private final TeamRepository teamRepository;

    @Transactional
    public void registerTeam(RegistrationTeamRequestDto requestDto) {
        validateTeam(requestDto);

        Team team = requestDto.toEntity();

        this.teamRepository.save(team);
    }

    /**
     * 팀 유효성 검사
     * @param requestDto
     */
    private void validateTeam(RegistrationTeamRequestDto requestDto) {
        if (this.teamRepository.existsByName(requestDto.name())) {
            throw new TeamAlreadyExistsException("이미 존재하는 팀 이름입니다.");
        }
    }
}
  • 요청으로 온 DTO의 이름으로 유효한 팀인지 검사 후, 해당 DTO를 엔티티로 변환하고 저장시킨다.

컨트롤러 레이어

package me.sungbin.domain.team.controller;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import me.sungbin.domain.team.model.request.RegistrationTeamRequestDto;
import me.sungbin.domain.team.model.response.TeamInfoResponseDto;
import me.sungbin.domain.team.service.TeamService;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * @author : rovert
 * @packageName : me.sungbin.domain.team.controller
 * @fileName : TeamController
 * @date : 3/1/24
 * @description :
 * ===========================================================
 * DATE 			AUTHOR			 NOTE
 * -----------------------------------------------------------
 * 3/1/24       rovert         최초 생성
 */

@RestController
@RequestMapping("/api/team")
@RequiredArgsConstructor
public class TeamController {

    private final TeamService teamService;

    @PostMapping("/register")
    public void registerTeam(@RequestBody @Valid RegistrationTeamRequestDto requestDto) {
        this.teamService.registerTeam(requestDto);
    }
}

 

테스트 결과

성공(포스트맨)

image

실패(이미 중복된 팀 이름)

image

실패 (팀 이름이 공란이거나 null)

image

테스트 코드
package me.sungbin.domain.team.controller;

import me.sungbin.domain.team.entity.Team;
import me.sungbin.domain.team.model.request.RegistrationTeamRequestDto;
import me.sungbin.domain.team.repository.TeamRepository;
import me.sungbin.global.common.controller.BaseControllerTest;
import me.sungbin.global.exception.GlobalExceptionCode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

/**
 * @author : rovert
 * @packageName : me.sungbin.domain.team.controller
 * @fileName : TeamControllerTest
 * @date : 3/1/24
 * @description :
 * ===========================================================
 * DATE 			AUTHOR			 NOTE
 * -----------------------------------------------------------
 * 3/1/24       rovert         최초 생성
 */

class TeamControllerTest extends BaseControllerTest {

    @Autowired
    private TeamRepository teamRepository;

    @BeforeEach
    void setup() {
        Team team = new Team("개발팀");
        this.teamRepository.save(team);
    }

    @Test
    @DisplayName("팀 등록 테스트 - 실패 (팀 이름이 공란)")
    void register_team_test_fail_caused_by_team_name_is_empty() throws Exception {
        RegistrationTeamRequestDto requestDto = new RegistrationTeamRequestDto("");

        this.mockMvc.perform(post("/api/team/register")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("message").exists())
                .andExpect(jsonPath("status").value(GlobalExceptionCode.INVALID_INPUT_VALUE.getHttpStatus().name()))
                .andExpect(jsonPath("code").value(GlobalExceptionCode.INVALID_INPUT_VALUE.getCode()))
                .andExpect(jsonPath("errors").exists())
                .andExpect(jsonPath("errors").isNotEmpty())
                .andExpect(jsonPath("timestamp").exists());
    }

    @Test
    @DisplayName("팀 등록 테스트 - 실패 (이미 존재하는 팀)")
    void register_team_test_fail_caused_by_already_exists_team() throws Exception {
        RegistrationTeamRequestDto requestDto = new RegistrationTeamRequestDto("개발팀");

        this.mockMvc.perform(post("/api/team/register")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("message").exists())
                .andExpect(jsonPath("status").value(GlobalExceptionCode.INVALID_INPUT_VALUE.getHttpStatus().name()))
                .andExpect(jsonPath("code").value(GlobalExceptionCode.INVALID_INPUT_VALUE.getCode()))
                .andExpect(jsonPath("errors").exists())
                .andExpect(jsonPath("errors").isEmpty())
                .andExpect(jsonPath("timestamp").exists());
    }

    @Test
    @DisplayName("팀 등록 테스트 - 성공")
    void register_team_test_success() throws Exception {
        RegistrationTeamRequestDto requestDto = new RegistrationTeamRequestDto("디자인팀");

        this.mockMvc.perform(post("/api/team/register")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("팀 정보 조회 테스트 - 성공")
    void find_team_info_test_success() throws Exception {
        this.mockMvc.perform(get("/api/team")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isOk());
    }
}
  • 기본으로 사용하는 어노테이션들을 아래의 어노테이션으로 묶음

package me.sungbin.global.common.annotation;

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @author : rovert
 * @packageName : me.sungbin.global.annotation
 * @fileName : IntegrationTest
 * @date : 3/1/24
 * @description :
 * ===========================================================
 * DATE 			AUTHOR			 NOTE
 * -----------------------------------------------------------
 * 3/1/24       rovert         최초 생성
 */

@Transactional
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface IntegrationTest {
}
  • 이 어노테이션을 BaseControllerTest라는 클래스에 선언

@Disabled
@IntegrationTest
public class BaseControllerTest {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;
}

추가적으로 test 디렉터리에도 resources 디렉터리 생성 후 해당 경로에 application.yml을 생성후 테스트 profile active시켜두었다.

spring:
  profiles:
    active: test

---

spring:
  config:
    activate:
      on-profile: test
  datasource:
    url: "jdbc:h2:mem:commutedb"
    username: sa
    password:
    driver-class-name: org.h2.Driver
  jpa:
    properties:
      hibernate:
        show_sql: true
        format_sql: true
    open-in-view: false
  threads:
    virtual:
      enabled: true

직원 등록 기능

🤔 고려점

1. 직원을 먼저 생성한다. (필수 값들은 공란일 수 없음)

2. 해당 직원을 팀에 등록 시킨다. (단, 등록할 직원이 매니저인 경우 해당 팀의 매니저가 없어야 한다.)

3. 등록하려는 팀이 존재해야 한다.

주요 코드를 보자. 먼저 연관관계 매핑을 해야한다.

package me.sungbin.domain.employee.entity;

import jakarta.persistence.*;
import lombok.*;
import me.sungbin.domain.employee.type.Role;
import me.sungbin.domain.team.entity.Team;
import me.sungbin.global.common.entity.BaseDateTimeEntity;
import org.hibernate.annotations.Comment;

import java.time.LocalDate;

/**
 * @author : rovert
 * @packageName : me.sungbin.domain.member.entity
 * @fileName : Member
 * @date : 3/1/24
 * @description :
 * ===========================================================
 * DATE 			AUTHOR			 NOTE
 * -----------------------------------------------------------
 * 3/1/24       rovert         최초 생성
 */

@Entity
@Getter
@EqualsAndHashCode(of = "id", callSuper = false)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AttributeOverrides({
        @AttributeOverride(name = "createdAt", column = @Column(name = "work_start_date", nullable = false, updatable = false)),
        @AttributeOverride(name = "updatedAt", column = @Column(name = "updated_at", nullable = false))
})
public class Employee extends BaseDateTimeEntity {

    @Id
    @Comment("직원 테이블 PK")
    @Column(name = "employee_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Comment("직원 이름")
    @Column(name = "employee_name", nullable = false)
    private String name;

    @Comment("팀의 매니저인지 아닌지 여부")
    @Column(nullable = false)
    private boolean isManager;

    @Column(nullable = false)
    private LocalDate birthday;

    @Builder
    public Employee(String name, boolean isManager, LocalDate birthday) {
        this.name = name;
        this.isManager = isManager;
        this.birthday = birthday;
    }

    @ManyToOne(fetch = FetchType.LAZY)
    private Team team;

    public void updateTeam(Team team) {
        this.team = team;
    }

    public String getTeamName() {
        return this.team.getName();
    }

    public String getRole() {
        return isManager ? Role.MANAGER.name() : Role.MEMBER.name();
    }
}

 

package me.sungbin.domain.team.entity;

import jakarta.persistence.*;
import lombok.*;
import me.sungbin.domain.employee.entity.Employee;
import me.sungbin.global.common.entity.BaseDateTimeEntity;
import org.hibernate.annotations.Comment;

import java.util.ArrayList;
import java.util.List;

/**
 * @author : rovert
 * @packageName : me.sungbin.domain.team.entity
 * @fileName : Team
 * @date : 3/1/24
 * @description :
 * ===========================================================
 * DATE 			AUTHOR			 NOTE
 * -----------------------------------------------------------
 * 3/1/24       rovert         최초 생성
 */

@Entity
@Getter
@EqualsAndHashCode(of = "id", callSuper = false)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AttributeOverrides({
        @AttributeOverride(name = "createdAt", column = @Column(name = "created_at", nullable = false, updatable = false)),
        @AttributeOverride(name = "updatedAt", column = @Column(name = "updated_at", nullable = false))
})
public class Team extends BaseDateTimeEntity {

    @Id
    @Comment("팀 테이블 PK")
    @Column(name = "team_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Comment("팀 이름")
    @Column(name = "team_name", nullable = false, unique = true)
    private String name;

    @OneToMany(mappedBy = "team")
    private List<Employee> employees = new ArrayList<>();

    @Builder
    public Team(String name) {
        this.name = name;
    }

    public void addEmployee(Employee employee) {
        this.employees.add(employee);
        employee.updateTeam(this);
    }

    public String getManagerName() {
        return employees.stream()
                .filter(Employee::isManager)
                .map(Employee::getName)
                .findFirst()
                .orElse(null);
    }

    public boolean hasManager() {
        return this.employees.stream().anyMatch(Employee::isManager);
    }

    public int getEmployeeCount() {
        return employees != null ? employees.size() : 0;
    }
}

위와 같이 연관관계 매핑을 해준다. 여기서 Employee의 getRole부분의 메서드의 Role은 enum타입으로 아래와 같이 되어 있다.

package me.sungbin.domain.employee.type;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import me.sungbin.global.common.type.EnumType;

/**
 * @author : rovert
 * @packageName : me.sungbin.domain.member.type
 * @fileName : Role
 * @date : 3/1/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 3/1/24       rovert         최초 생성
 */

@Getter
@RequiredArgsConstructor
public enum Role implements EnumType {
    MEMBER("MEMBER", "팀원"),
    MANAGER("MANAGER", "매니저");

    private final String name;

    private final String description;
}

위의 코드를 보면 EnumType이라는 인터페이스가 있는데 그 안에는 아래와 같다.

package me.sungbin.global.common.type;

/**
 * @author : rovert
 * @packageName : me.sungbin.global.common.type
 * @fileName : EnumType
 * @date : 3/1/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 3/1/24       rovert         최초 생성
 */
public interface EnumType {
    String name();

    String getDescription();
}

이렇게 한 이유는 나중의 확장성 때문에 구현을 해둔 것이다.

@Transactional
public void registerEmployee(RegistrationEmployeeRequestDto requestDto) {
   Employee employee = requestDto.toEntity();

   Team team = this.teamRepository.findByName(requestDto.teamName()).orElseThrow(TeamNotFoundException::new);

   // 매니저가 이미 존재하는 경우 예외 발생
   if (employee.isManager() && team.hasManager()) {
       throw new AlreadyExistsManagerException("이미 매니저가 해당 팀에 존재합니다.");
   }

   this.employeeRepository.save(employee);
   team.addEmployee(employee);
   this.teamRepository.save(team);
}

그리고 위와 같이 서비스 로직을 작성해준다. 해당 로직은 dto로부터 엔티티화 시키고 요청한 팀의 이름으로 팀이 존재하는지 찾는다.

만약 없으면 예외를, 있다면 해당 팀에 매니저가 존재하는지 유무도 추가해두었다. 이미 있다면 예외를 없다면 해당 직원을 저장시킨다.

 

컨트롤러 레이어

package me.sungbin.domain.employee.controller;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import me.sungbin.domain.employee.model.request.EmployeesInfoResponseDto;
import me.sungbin.domain.employee.model.request.RegistrationEmployeeRequestDto;
import me.sungbin.domain.employee.service.EmployeeService;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * @author : rovert
 * @packageName : me.sungbin.domain.member.controller
 * @fileName : EmployeeController
 * @date : 3/1/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 3/1/24       rovert         최초 생성
 */

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/employee")
public class EmployeeController {

    private final EmployeeService employeeService;

    @PostMapping("/register")
    public void registerEmployee(@RequestBody @Valid RegistrationEmployeeRequestDto requestDto) {
        this.employeeService.registerEmployee(requestDto);
    }
}

 

테스트

성공

image

실패 (존재하는 팀이 없음)

image

실패(이미 그 팀에 매니저가 있음)

image

테스트코드

class EmployeeControllerTest extends BaseControllerTest {

    @Autowired
    private TeamRepository teamRepository;

    @Autowired
    private EmployeeRepository employeeRepository;

    @BeforeEach
    void setup() {
        Team team = new Team("개발팀");
        this.teamRepository.save(team);
    }

    @Test
    @DisplayName("직원 등록 테스트 - 실패 (잘못된 입력 값)")
    void register_employee_test_fail_caused_by_wrong_input() throws Exception {
        RegistrationEmployeeRequestDto requestDto = new RegistrationEmployeeRequestDto("", "", false, LocalDate.of(1996, 5, 22));

        this.mockMvc.perform(post("/api/employee/register")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("message").exists())
                .andExpect(jsonPath("status").value(GlobalExceptionCode.INVALID_INPUT_VALUE.getHttpStatus().name()))
                .andExpect(jsonPath("code").value(GlobalExceptionCode.INVALID_INPUT_VALUE.getCode()))
                .andExpect(jsonPath("errors").exists())
                .andExpect(jsonPath("errors").isNotEmpty())
                .andExpect(jsonPath("timestamp").exists());
    }

    @Test
    @DisplayName("직원 등록 테스트 - 실패 (존재하지 않는 팀에 등록)")
    void register_employee_test_fail_caused_by_register_not_exists_team() throws Exception {
        RegistrationEmployeeRequestDto requestDto = new RegistrationEmployeeRequestDto("장그래", "영업팀", false, LocalDate.of(1992, 2, 22));

        this.mockMvc.perform(post("/api/employee/register")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("message").exists())
                .andExpect(jsonPath("status").value(GlobalExceptionCode.INVALID_INPUT_VALUE.getHttpStatus().name()))
                .andExpect(jsonPath("code").value(GlobalExceptionCode.INVALID_INPUT_VALUE.getCode()))
                .andExpect(jsonPath("errors").exists())
                .andExpect(jsonPath("errors").isEmpty())
                .andExpect(jsonPath("timestamp").exists());
    }

    @Test
    @DisplayName("직원 등록 테스트 - 성공")
    void register_employee_test_success() throws Exception {
        RegistrationEmployeeRequestDto requestDto = new RegistrationEmployeeRequestDto("양성빈", "개발팀", false, LocalDate.of(1996, 5, 22));

        this.mockMvc.perform(post("/api/employee/register")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isOk());
    }
}

팀 조회 기능

서비스 레이어

public List<TeamInfoResponseDto> findTeamInfo() {
    List<Team> teams = this.teamRepository.findAll();

    return teams.stream().map(TeamInfoResponseDto::new).toList();
}

해당 팀들을 findAll로 select한 이후로 응답 DTO로 매핑해준다.

아래는 포스트맨 테스트 결과다.

image

이제 테스트 코드를 살펴보자.

@Test
@DisplayName("팀 정보 조회 테스트 - 성공")
void find_team_info_test_success() throws Exception {
    this.mockMvc.perform(get("/api/team")
                    .contentType(MediaType.APPLICATION_JSON)
                    .accept(MediaType.APPLICATION_JSON))
            .andDo(print())
            .andExpect(status().isOk());
}

직원 조회 기능

서비스 레이어

public List<EmployeesInfoResponseDto> findEmployeesInfo() {
    List<Employee> employees = this.employeeRepository.findAll();

    return employees.stream().map(EmployeesInfoResponseDto::new).toList();
}

전체 직원을 select하여 stream 객체를 이용하여 응답 DTO와 매핑해주었다. 아래는 테스트 결과다.

image

아래는 테스트 코드다.

@Test
@DisplayName("직원 정보 조회 테스트 - 성공")
void find_employees_info_test_success() throws Exception {
    this.mockMvc.perform(get("/api/employee")
                    .contentType(MediaType.APPLICATION_JSON)
                    .accept(MediaType.APPLICATION_JSON))
            .andDo(print())
            .andExpect(status().isOk());
}

회고

1단계는 이제까지 우리가 배운 개념들로 충분히 개발 할 수 있는 것들이였다. 하지만 나는 여기서 더 나아가서 좀 더 예외상황을 생각해보고 더 발전시키도록 노력했다. 그리고 또한 다른 러너분들과 코드리뷰를 통해 내 코드를 리팩토링 해가면서 뭔가 실력이 점점 쌓여만 가는 것 같았다.

 

댓글을 작성해보세요.