BindingResult

  • Validation 오류를 보관하는 객체
  • 여기서 검증할 때 사용했던 객체임!
 @PostMapping
    public ResponseEntity<ScheduleResponseDto> createSchedule(@RequestBody @Valid ScheduleSaveRequestDto requestDto, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            throw new InvalidRequestException(INVALID_REQUEST_EXCEPTION);
        }
        return new ResponseEntity<>(scheduleService.saveSchedule(requestDto), HttpStatus.CREATED);
    }

 

  • 파라미터에 BindingResult가 없는 경우 -> 검증 오류(400 Bad Request)가 발생하고 Controller가 호출되지 않음

 

  • 파라미터에 BindingResult가 있다면 -> BindingResult에 오류가 보관되고 Controller는 정상적으로 호출
    • ModelAttribute는 파라미터를 필드 하나하나에 바인딩함 -> 만약 그중 하나의 필드에 오류가 발생하면 해당 필드를 제외하고 나머지 필드들만 바인딩 된 후 Controller가 호출
    • 제대로 입력한 값은 잘 전달되어 화면에 나타나고, 에러는 BindingResult에 보관됨

@Controller
public class BindingResultController {

    @PostMapping("/v2/member")
    public String createMemberV2(
            // 1. @ModelAttribute 뒤에 2. BindingResult가 위치한다.
            @ModelAttribute MemberCreateRequestDto request,
            BindingResult bindingResult,
            Model model
    ) {

        System.out.println("/V2/member API가 호출되었습니다.");

        // BindingResult의 에러 출력
        List<ObjectError> allErrors = bindingResult.getAllErrors();
        System.out.println("allErrors = " + allErrors);

        // Model에 저장
        model.addAttribute("point", request.getPoint());
        model.addAttribute("name", request.getName());
        model.addAttribute("age", request.getAge());

        return "complete";
    }

 

=> BindingResult를 이용해서 에러를 검증하는 것은 Controller의 크기가 커지고, 단일 책임 원칙에 위반 ⭐️

=>  Bean Validation을 사용하자 ~!

 

@Valid, @Validated 차이점 (너무 궁금했음)

  • @Valid - 자바 표준 / @Validated - 스프링 표준
  • @Validated는 Group Validation이나 Controller이외 계층에서 검증 가능
  • @Valid -> MethodArgumentNotValidException 발생
  • @Validated -> ConstraintViolationException 발생

 

Validator

  • 라이브러리를 추가하면 Bean Validator가 @Valid, @Validated를 통하여 검증할 수 있게 해줌
  • @Valid, @Validated를 사용하는 것 =  Validator 사용
  • Bean Validator는 바인딩에 실패한 필드는 Bean Validation을 적용하지 않음 (BindingResult에 에러 추가하고 값은 null값)
  • 바인딩 실패 : String타입에 Integer입력한 것

 

에러 메세지 수정법

 

1. message 속성 사용

@NotBlank(message = "메세지 수정 가능")
	private String stringField;

 

2. Object Error : 로직으로 구현

public class BeanValidationController {

    @PostMapping("/object-error")
    public String objectError(
            @Validated @ModelAttribute OrderRequestDto requestDto,
            BindingResult bindingResult
    ) {

        // 합이 10000원 이상인지 확인
        int result = requestDto.getPrice() * requestDto.getCount();
        if (result < 10000) {
            // Object Error
            bindingResult.reject("totalMin", new Object[]{10000, result}, "총 합이 10000 이상이어야 합니다.");
        }

        return "성공";
    }
    
}

 

Bean Validation 충돌 : Dto 나누기

  • Dto를 나눌 것 (요청 Dto, 수정 Dto)
  • 명확하고, 유지보수성 측면에서 좋음

 

@ModelAttribute, @RequestBody의 차이

@ModelAttribute

  • 파라미터, Form Data(x-www-urlencoded)를 다룰 때 사용
  • 필드 단위로 변환하기 때문에 특정 필드 변환 바인딩이 실패해도 Controller를 호출하고 나머지 필드는 Validation 적용함

@RequestBody

  • HTTP Body Data를 Object로 변환
  • 객체 단위로 적용되기에 변환이 완벽하게 되야만 Validation 적용
  • 변환이 실패된다면 Controller를 미호출

의존관계 주입 방법

@Autowired : 스프링이 스프링 컨테이너에 등록된 빈의 의존성을 주입해 줄 때 사용

1. 생성자 주입

최초에 한번 생성된 후 값이 변경되지 못함 = 불변

필드에 final을 유일하게 가짐 -> 유일하게 객체가 생성될 시점에 의존관계가 주입되면서 필드의 값이 생성

@Autowired
    public MyApp(MyService myService) {
        this.myService = myService;
    }

 

2. Setter 주입

변경 가능한 의존관계에 사용

@Autowired
    public void setMyService(MyService myService) {
        this.myService = myService;
    }

 

 

3. 필드 주입 (사용하지 말 것)

클래스 내부 필드에 주입, Spring없이 순수 자바 코드로는 불가능하기에 @SpringBootTest 테스트 코드에만 사용

@Component
public class MyApp {

    @Autowired
    private MyService myService;  // 필드에 직접 주입
    
}

 

의존관계 주입은 생성자 주입을 사용하자

  • 생성자 주입을 사용해야 객체 생성시 최초 한 번만 호출하게 되어 의존성을 주입한 객체가 변경될 여지가 없음
  • 테스트 코드 작성 시(순수 Java) 객체를 생성할 때, 생성자의 필드를 필수로 입력하게 되므로 실수를 방지
  • 객체 지향의 특성을 가장 잘 이용함
  • @RequiredArgsConstructor 사용할 것 ( final 붙은 필드를 모아서 생성자 주입 )

 

수동 Bean 등록을 사용하는 경우 (@Configuration에서 @Bean)

  • 외부 라이브러리나 객체를 Spring Bean으로 등록할 때 -> 자동 등록 불가
  • 데이터베이스 연결과 같이 비지니스 로직을 지원하는 기술 사용
  • 같은 타입의 Bean 여러개 중 하나를 명시적으로 선택할 때

 

Spring Container

  • 스프링 애플리케이션에서 객체를 생성, 관리, 소멸하는 역할
  • Bean(Spring Container에 의해 관리되는 객체)을 생성 및 관리
  • Bean 간의 의존 관계(A객체에서 B객체를 사용하는 것)를 스프링이 설정
  • Spring Container의 최상위 인터페이스인 BeanFactory의 확장된 형태인 ApplicationContext를 의미함

-> Spring Container를 통해서 OCP, DIP 준수할 수 있음

 

Spring Bean

  • Spring Container에 의해 관리되는 객체
  • Singleton으로 설정 (= 클래스의 인스턴스가 오직 하나만 생성되도록 보장)
  • 존성 주입(DI)을 통해 다른 객체들과 의존 관계를 맺음
  • 생성, 초기화, 사용, 소멸의 생명주기

 

Spring Bean 등록방법

 

@ComponentScan : 스프링이 @이 붙은 클래스를 자동으로 검색하고 Bean으로 등록하는 기능

 

@ComponentScan 순서

  1. Spring Application이 실행되면 @ComponentScan이 지정된 패키지를 탐색
  2. @ComponentScan은 main() 메서드가 있는 클래스 상단에 @SpringBootApplication에 존재함
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
       @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {

  3.  해당 패키지에서 @Component 또는 Annotation이 붙은 클래스를 찾고, Spring 컨테이너에 빈으로 등록

  4. 의존성 주입을 통해서 등록된 빈은 다른 빈과 연결됨

 

@Component 종류

@Component - 스프링에게 해당 객체를 Bean으로 등록하겠다는 의미
@Service - 스프링에게 해당 객체를 Bean으로 등록하면서 서비스 계층이라고 알리는 의미
@Repository - 스프링에게 해당 객체를 Bean으로 등록하면서 리포지토리 계층이라고 알리는 의미
@Controller - 스프링에게 해당 객체를 Bean으로 등록하면서 컨트롤러 계틍이라고 알리는 의미

 

실제로 @Service를 까보면 안에 @Component가 있음

 

Bean 충돌

  • 자동 Bean 등록 = (@ComponentScan, @Component)
    • @Component 이 있는 클래스의 앞글자만 소문자로 변경하여 Bean 이름으로 등록
    • @ComponentScan 을 통해 @Component로 설정된 클래스를 찾는 것
  • 수동 Bean 등록(@Configuration, @Bean)
    • @Configuration 이 있는 클래스를 Bean으로 등록하고 해당 클래스를 파싱해서 @Bean 이 있는 메서드를 찾아 Bean을 생성
// 인터페이스
public interface TestService {
    void doSomething();
}

// 인터페이스 구현체
public class TestServiceImpl implements TestService {
    @Override
    public void doSomething() {
        System.out.println("Test Service 메서드 호출");
    }
}

// 수동으로 빈 등록
@Configuration
public class AppConfig {
    
    // TestService 타입의 Spring Bean 등록
    @Bean
    public TestService testService() {
        // TestServiceImpl을 Bean으로 등록
        return new TestServiceImpl();
    }
    
}

// Spring Bean으로 등록이 되었는지 확인
public class MainApp {
    public static void main(String[] args) {
        // Spring ApplicationContext 생성 및 설정 클래스(AppConfig) 등록
        ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);

        // 등록된 TestService 빈 가져오기
        TestService service = context.getBean(TestService.class);

        // 빈 메서드 호출
        service.doSomething();
    }
}
  • 자동 Bean 등록 VS 자동 Bean 등록 충돌 : ConflictingBeanDefinitionException 발생
  • 자동 Bean 등록 VS 수동 Bean 등록 충돌 : 수동이 자동을 오버라이딩

 

Singleton Pattern

  • 클래스의 인스턴스가 오직 하나만 생성되도록 보장하는 디자인 패턴
  • 스프링 빈을 싱글톤 패턴으로 적용하지 않으면, 다수의 고객이 요청할 때마다 생성하고 소멸되기 때문에 메모리 낭비
  • 싱글톤 패턴을 직접 구현하려면 복잡하고 솔리드 원칙을 위반하게 됨 -> Spring Container는 싱글톤 패턴의 문제점들을 해결하면서 객체를 싱글톤으로 관리
  • Spring Bean은 인스턴스가 1개 이기 때문에 항상 무상태(stateless)로 설계를 해야함

IOC(제어의 역전, Inversion Of Control)

  • 객체의 생성과 관리 권한을 개발자가 아닌 Spring 컨테이너가 담당하는 것
  • (제어를 내가 아니라 프로그램이 했으니깐, 역전된 것!)

 

DI(의존성 주입, Dependency Injection)

  • 한 객체가 다른 객체를 사용할 때, 해당 객체를 직접 생성하지 않고 Spring이 주입해주는 방식
  • Spring이 객체 간의 의존성을 자동으로 주입해주는 것

단일 책임 원칙 SRP(Single Responsibility Principle)

  • 하나의 클래스는 한 가지의 책임만 가져야 함
  • 클래스가 변경될 때 파급 효과가 작아야 함

예시

  • 단일책임 원칙 위반
    • User 클래스는 사용자 정보, 로그인기능, 사용자정보를 데이터베이스에 저장하는 내용 3가지의 기능을 모두 수행하고 있음
public class User {
		private String name; // 사용자 정보
    	public void login() { /* 로그인 기능 */ }
    	public void saveUser() { /* 데이터베이스 저장 기능 */ }
}

 

  • 단일 책임 원칙 적용
    • User는 사용자의 정보만 관리하고, 서비스는 로그인 기능을 담당하고, 리포지토리는 데이터베이스에 사용자 정보를 저장하는 기능 담당하는 책임을 가짐
public class User { /* 사용자 정보 관리 */ }

public class AuthService {
    public void login(User user) { /* 로그인 기능 */ }
}

public class UserRepository {
    public void saveUser(User user) { /* 데이터베이스 저장 */ }
}

 

개방 폐쇄 원칙 OCP(Open Closed Principle)

  • 확장에는 열려 있고, 수정에는 닫혀 있어야 함 = 새로운 기능을 추가할 때 기존 코드의 수정 없이 추가할 수 있어야 함

예시

  • 개방 폐쇄 원칙 위반
    • 새로운 도형이 추가 될 때마다, AreaCalculator 클래스의 calculate 메서드 내부를 if문을 사용하여 수정해야 함
public class Shape {
    public String type;
}

public class AreaCalculator {
    public double calculate(Shape shape) {
        if (shape.type.equals("circle")) {
            return /* 원의 넓이 계산 */;
        } else if (shape.type.equals("square")) {
            return /* 사각형의 넓이 계산 */;
        }
    }
}

 

  • 개방 폐쇄 원칙 적용
    • 새로운 도형이 추가되어도, Shape인터페이스를 상속하여 이를 구현하면 되기 때문에 AreaCalculator를 수정할 필요가 없음
    • 다형성을 사용 = 역할(도형)과 구현(원,삼각형)을 분리한 것
public interface Shape {
    double calculateArea();
}

public class Circle implements Shape {
    public double calculateArea() { return /* 원의 넓이 계산 */; }
}

public class Square implements Shape {
    public double calculateArea() { return /* 사각형의 넓이 계산 */; }
}

public class AreaCalculator {
    public double calculate(Shape shape) {
        return shape.calculateArea();
    }
}

 

 

리스코프 치환 원칙 LSP(Liskov Substitution Principle)

  • 자식 클래스는 부모 클래스를 대체 할 수 있어야 함
  • 부모 클래스를 사용하는 곳에서 자식 클래스를 사용해도 프로그램 동작에 문제가 없어야 함

예시

  • 리스코프 치환 원칙 위반
    • ElectricCar는 Car 클래스를 상속 받았지만, accelerate() 를 사용할 수 없음
class Car {
    public void accelerate() {
        System.out.println("자동차가 휘발유로 가속합니다.");
    }
}

class ElectricCar extends Car {
    @Override
    public void accelerate() {
        throw new UnsupportedOperationException("전기차는 이 방식으로 가속하지 않습니다.");
    }
}

public class Main {
    public static void main(String[] args) {
        Car car = new Car();
        car.accelerate(); // "자동차가 가속합니다."

        Car electricCar = new ElectricCar();
        electricCar.accelerate(); // UnsupportedOperationException 발생
    }
}

 

  • 리스코프 치환 원칙 적용
    • 가속 기능(역할)을 인터페이스로 분리
interface Acceleratable {
    void accelerate();
}

class Car implements Acceleratable {
    @Override
    public void accelerate() {
        System.out.println("내연기관 자동차가 가속합니다.");
    }
}

class ElectricCar implements Acceleratable {
    @Override
    public void accelerate() {
        System.out.println("전기차가 배터리로 가속합니다.");
    }
}

public class Main {
    public static void main(String[] args) {
        Acceleratable car = new Car();
        car.accelerate(); // "내연기관 자동차가 가속합니다."

        Acceleratable electricCar = new ElectricCar();
        electricCar.accelerate(); // "전기차가 배터리로 가속합니다."
    }
}

 

인터페이스 분리 원칙 ISP(Interface Segregation Principle)

  • 클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야

예시

  • 인터페이스 분리 원칙 위반
    • Dog은 사용하지 않는 fly 메서드를 구현해야 함
public interface Animal {
    void fly();
    void run();
    void swim();
}

public class Dog implements Animal {
    public void fly() { /* 사용하지 않음 */ }
    public void run() { /* 달리기 */ }
    public void swim() { /* 수영 */ }
}

 

  • 인터페이스 분리 원칙 적용
public interface Runnable {
    void run();
}

public interface Swimmable {
    void swim();
}

public class Dog implements Runnable, Swimmable {
    public void run() { /* 달리기 */ }
    public void swim() { /* 수영 */ }
}

 

의존관계 역전 원칙 DIP(Dependency Inversion Principle)

  • 구체적인 클래스에 의존하지 말고 인터페이스나 추상 클래스에 의존해야 함

 

예시

  • 의존관계 역전 원칙 위반
    • SMS 알림과 같은 기능이 추가되면 NotificationService 는 수정되어야 함
// Email 알림 클래스
class EmailNotifier {
    public void sendEmail(String message) {
        System.out.println("Email 알림: " + message);
    }
}

// 알림 시스템
class NotificationService {
    private EmailNotifier emailNotifier;

    public NotificationService() {
		    // 구체적인 클래스인 EmailNotifier에 의존
        this.emailNotifier = new EmailNotifier();
    }

    public void sendNotification(String message) {
        emailNotifier.sendEmail(message);
    }
}

public class Main {
    public static void main(String[] args) {
        NotificationService service = new NotificationService();
        service.sendNotification("안녕하세요! 이메일 알림입니다.");
    }
}

 

  • 적용
// 알림 인터페이스(추상화)
interface Notifier {
    void send(String message);
}

// Email 알림 클래스
class EmailNotifier implements Notifier {
    @Override
    public void send(String message) {
        System.out.println("Email 알림: " + message);
    }
}

// SMS 알림 클래스
class SMSNotifier implements Notifier {
    @Override
    public void send(String message) {
        System.out.println("SMS 알림: " + message);
    }
}

// 알림 서비스 (높은 수준 모듈)
class NotificationService {
		// 추상화된 인터페이스에 의존
    private Notifier notifier;

    // 의존성 주입 (생성자를 통해 주입)
    public NotificationService(Notifier notifier) {
        this.notifier = notifier;
    }

    public void sendNotification(String message) {
		    // notifier가 어떤 구현체인지 상관하지 않음
        notifier.send(message);
    }
}

public class Main {
    public static void main(String[] args) {
        // Email 알림을 사용
        Notifier emailNotifier = new EmailNotifier();
        NotificationService emailService = new NotificationService(emailNotifier);
        emailService.sendNotification("안녕하세요! 이메일 알림입니다.");

        // SMS 알림을 사용
        Notifier smsNotifier = new SMSNotifier();
        NotificationService smsService = new NotificationService(smsNotifier);
        smsService.sendNotification("안녕하세요! SMS 알림입니다.");
    }
}

 

다형성의 한계 : OCP, DIP의 위반과 Spring

OCP 위반 (수정없이 기능을 확장)

  • 구현 객체를 변경하기 위해서는 해당 코드를 사용하는 클라이언트측의 코드를 변경해야 함
// Circle을 계산하는 경우
public class Main {
		public static void main(String[]) {
		
			AreaCalculator areaCalculator = new AreaCalculator();
			Circle circle = new Circle();
				
			areaCalculator.calculate(circle);
			
		}
}

// Square를 계산하는 경우
public class Main {
		public static void main(String[]) {
		
			AreaCalculator areaCalculator = new AreaCalculator();
			// Circle circle = new Circle();
			Square square = new Square();
				
			areaCalculator.calculate(square);
			
		}
}

 

DIP 위반 (구체화 말고 추상화에 의존)

  • 클라리언트가 OrderService(=인터페이스)를 의존하고 있지만, RateDiscountPolicy라는 구현클래스에도 의존하고 있으므로 위반
 public class OrderServiceImpl implements OrderService {
 //    private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
     private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
 }

 

⭐️ 결론 : Spring => OCP, DIP를 IOC, DI를 통해 가능하도록 만들어준다. ⭐️

ERD 에서 일정과 작성자를 분리

  • 일정 테이블과 회원 테이블을 분리
  • 회원 : 일정 = 1 : N 관계
  • pk는 aoto increment로 자동 증가되도록 관리

 

  • 테이블 생성 쿼리

 

일정 엔티티와 회원 엔티티 생성

회원 엔티티

@Getter
@AllArgsConstructor
public class Member {
    private Long id;
    private String name;
    private String email;
    private LocalDateTime createDate;
    private LocalDateTime updatedDate;

}

 

일정 엔티티 

@Getter
@AllArgsConstructor
public class Schedule {

    private Long id;
    private String content;
    private Long memberId;
    private String password;
    private LocalDateTime createDate;
    private LocalDateTime modifiedDate;
  }

 

연관관계 적용 - 저장 

  • 일정 저장시에 memberId값도 함께 추가하여 등록 (scheule table에 들어갈 member table의 pk값)
@Getter
public class ScheduleSaveRequestDto {
    private Long memberId;

    // NotBlank를 사용해야 null, 빈 문자열(""), 공백(" ") 모두 허용하지 않음
    @NotBlank
    @Max(value = 200)
    private String content;

    @NotBlank
    private String password;
}

 

  • 리포지토리에서 해당 값을 함께 담아서 schedule table에 insert!
@Override
    public ScheduleResponseDto saveSchedule(Schedule schedule) {
        SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
        jdbcInsert.withTableName("schedule").usingGeneratedKeyColumns("id");

        Map<String, Object> parameters = new HashMap<>();
        parameters.put("content", schedule.getContent());
        parameters.put("member_id", schedule.getMemberId());
      	...

        Number key = jdbcInsert.executeAndReturnKey(new MapSqlParameterSource(parameters));
        return new ScheduleResponseDto(key.longValue(), schedule.getContent(), schedule.getMemberId(), LocalDateTime.now(), LocalDateTime.now());
    }

 

연관관계 적용 - 조회

  • 일정 조회시에 작성자의 이름을 함께 가져오기
  • 작성자의 이름Member 테이블에 있기 때문에 join하여 조회!
@Override
    public List<SchedulePagingResponseDto> findSchedules(Long offset, int size) {
        return jdbcTemplate.query("select s.id, s.content, m.name, s.created_at, s.updated_at from schedule as s inner join member as m on s.member_id = m.id limit ?, ?", scheduleRowMapperV4(), offset, size);
    }

 

  • 작성자의 이름이 응답객체에 포함되기 때문에 이름을 포함한 전용 Mapper 생성
public RowMapper<SchedulePagingResponseDto> scheduleRowMapperV4(){
        return new RowMapper<SchedulePagingResponseDto>() {
            @Override
            public SchedulePagingResponseDto mapRow(ResultSet rs, int rowNum) throws SQLException {
                return new SchedulePagingResponseDto(
                        rs.getLong("id"),
                        rs.getString("content"),
                        rs.getString("name"),
                        rs.getTimestamp("created_at").toLocalDateTime(),
                        rs.getTimestamp("updated_at").toLocalDateTime()
                );
            }
        };
    }

 

페이징 적용

고민했던 점

  • LIMIT의 개수는 고정적이기에 상관없지만 OFFSET의 경우는 어떤 식으로 지정해야 할지 고민됐음
  •  OFFSET의 공식을 사용해서 해결해야 하나 고민이 많이 됐는데 Pageable객체에서 사용할 수 있는게 뭐가 있는지 찾아보다 getOffet을 발견하고 해결! 

페이징 쿼리

  • schdule테이블의 행을 10번째부터 시작해서 20개를 가져오겠다는 의미
  • LIMIT : 가져올 행의 개수
  • OFFSET : 건너뛸 행의 개수
SELECT * 
FROM schedule 
LIMIT 10, 20;

 

구현

  • 페이지의 값과 각 페이지별 크기를 쿼리 파라미터로 입력받음
@GetMapping("/paged")
    public ResponseEntity<Page<SchedulePagingResponseDto>> list(@RequestParam("page") int page, @RequestParam("size") int size){
        Page<SchedulePagingResponseDto> pagedList = scheduleService.findSchedulePageList(page, size);
        return new ResponseEntity<>(pagedList, HttpStatus.OK);
    }

 

  • Pageable : 페이징 정보를 담는 인터페이스
  • PageRequest : Pageable구현체
  • PageImpl<T> :  List<T>를 Page<T>로 변환
  • PageRequest를 이용하여 page와 size값을 세팅한 후에 Pageable로 생성
@Override
public Page<SchedulePagingResponseDto> findSchedulePageList(int page, int size) {
    int count = scheduleRepository.findScheduleSize();
    Pageable pageable = PageRequest.of(page, size);
    return new PageImpl<>(scheduleRepository.findSchedules(pageable.getOffset(), pageable.getPageSize()), pageable, count);
}

 

  • PageImpl객체를 생성할 때 데이터의 전체 값을 인자로 함께 넣어줘야 정확한 페이징이 이루어짐
new PageImpl<>(scheduleRepository.findSchedules(pageable.getOffset(), pageable.getPageSize()), pageable, count);

 

  • 리포지토리에서 Pageable객체를 이용하여 offset값과 size값으로 페이징 쿼리 실행 후 반환
@Override
    public List<SchedulePagingResponseDto> findSchedules(Long offset, int size) {
        return jdbcTemplate.query("select s.id, s.content, m.name, s.created_at, s.updated_at from schedule as s inner join member as m on s.member_id = m.id limit ?, ?", scheduleRowMapperV4(), offset, size);
    }

 

요청

http://localhost:8080/schedules/paged?page=2&size=3 (GET)

 

응답

 

입력값 검증

고민했던 점

  • 원래 파라미터의 id타입을 long으로 지정
  • 사용자가 잘못된 경우로 입력했을 때 MethodArgumentTypeMismatchException 발생
  • 해당 예외가 발생했을 때 직접 만든 에러 객체를 던지고 싶었기에 파라미터를 string으로 받고 검증 로직을 따로 추가해서 잘못된 경우로 입력했을 때InvalidRequestException(직접만든 예외)가 발생하도록 함 

쿼리 파라미터 검증

  • ValidationUtils class를 만들어서 쿼리 파라미터의 값을 모두 stirng형태로 받고, 검증을 수행
  • 검증이 완료되면(사용자가 숫자만 입력한 경우) 해당 값을 Long으로 바꾸고 반환
// 사용자의 입력값이 숫자로만 이루어 지지 않았을 경우에, 검증에 사용하는 Util Class
public class ValidationUtils {
    static final String NUMBER_REG = "[0-9]+";
    public static Long validNumberInputValue(String number) throws InvalidRequestException {
        if (!number.matches(NUMBER_REG)){
            throw new InvalidRequestException(INVALID_REQUEST_EXCEPTION);
        }
        return Long.parseLong(number);
    }

}

 

Dto 검증

  • @Valid를 사용하여 검증
  • @Email을 사용하는 경우에는 공백이 입력 가능하기 때문에 정규표현식으로 이메일 형식 적용
  • NotBlank를 사용해야 null, 빈 문자열(""), 공백(" ") 모두 허용하지 않음
@Getter
public class ScheduleSaveRequestDto {
    private Long memberId;

    // NotBlank를 사용해야 null, 빈 문자열(""), 공백(" ") 모두 허용하지 않음
    @NotBlank
    @Max(value = 200)
    private String content;

    @NotBlank
    private String password;
}

@Getter
public class MemberSaveRequestDto {

    private String name;

    // @Email을 사용하는 경우에는 공백이 입력 가능하기 때문에 정규표현식으로 이메일 형식 적용
    @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$", message = "이메일 형식이 올바르지 않습니다.")
    private String email;
}

 

예외 발생 처리

  • 에러 객체와 관련된 패키지

  • 에러 객체가 발생한 경우 사용하는 응답 객체를 공통으로 관리하기 위해 생성
@Getter
@Builder
public class ErrorResponse {

    private HttpStatus status;
    private String message;

    public ErrorResponse(HttpStatus status, String message){
        this.status = status;
        this.message = message;
    }

    public static ResponseEntity<ErrorResponse> toResponseEntity(ErrorCode e) {
        return ResponseEntity.status(e.getStatus().value())
                        .body(ErrorResponse.builder()
                        .status(e.getStatus())
                        .message(e.getMessage())
                        .build());
    }
}

 

 

  • 에러 객체의 코드를 관리하기 위한 enum class
@Getter
@AllArgsConstructor
public enum ErrorCode {
    INVALID_REQUEST_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 요청 정보입니다."),
    INVALID_PASSWORD_EXCEPTION(HttpStatus.UNAUTHORIZED, "올바르지 않은 비밀번호입니다."),
    SCHEDULE_NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, "존재하지 않는 일정입니다."),
    MEMBER_NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, "존재하지 않는 회원입니다.");

    private final HttpStatus status;
    private final String message;
}

 

  • 컨트롤러에서 발생하는 에러를 관리하는 ExceptionController
@RestControllerAdvice(assignableTypes = ScheduleController.class)
@Slf4j
public class ScheduleExceptionController {

    /*
    * 일정이 없는 경우 발생하는 에러객체를 처리
     */
    @ExceptionHandler(ScheduleNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleScheduleNotFoundException(ScheduleNotFoundException e) {
        log.error("[ScheduleExceptionHandler] ScheduleNotFoundException = {}, class = {}", e.getErrorCode().getMessage(), e.getClass());
//        return ResponseEntity.status(e.getErrorCode().getStatus()).body(new ErrorResponse(e.getErrorCode().getStatus(), e.getErrorCode().getMessage()));
        return ErrorResponse.toResponseEntity(e.getErrorCode());
    }

 

 

 

Server 구조

 

일정 생성

  • ScheduleRequestDto을 이용하여 생성 정보를 요청
  • 생성 날짜(created_at)와 수정 날짜(updated_at)는 서버의 Repository단에서 LocalDateTime.now()를 사용하여 값을 넣어줌
@Override
    public ScheduleResponseDto saveSchedule(Schedule schedule) {
        SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
        jdbcInsert.withTableName("schedule").usingGeneratedKeyColumns("id");

        Map<String, Object> parameters = new HashMap<>();
        parameters.put("content", schedule.getContent());
        parameters.put("author", schedule.getAuthor());
        parameters.put("password", schedule.getPassword());
        parameters.put("created_at", LocalDateTime.now());
        parameters.put("updated_at", LocalDateTime.now());

        Number key = jdbcInsert.executeAndReturnKey(new MapSqlParameterSource(parameters));
        return new ScheduleResponseDto(key.longValue(), schedule.getContent(), schedule.getAuthor(), schedule.getCreateDate(), schedule.getModifiedDate());
    }

 

전체 일정 조회하기

  • 수정일과 작성자명을 바탕으로 조회하라는 조건이 있었기 때문에 쿼리 파라미터로 각각의 정보를 받음
@GetMapping
    public ResponseEntity<List<ScheduleResponseDto>> findAllSchedules(
            @RequestParam("author") String author,
            @RequestParam("updated_at") String modifiedDate
    ){
        return new ResponseEntity<>(scheduleService.findAllSchedules(author, modifiedDate), HttpStatus.OK);
    }

 

고민했던 점

조회 결과가 "조건 중 한 가지만을 충족하거나, 둘 다 충족을 하지 않을 수도, 두 가지를 모두 충족할 수도 있습니다." 라고 명시되어 있었다

  • 조건 중 한가지나 둘 다 충족하는 경우는 쿼리에서 수정일이랑 작성자명을 가지고 where절이랑 or조건을 사용하여 해결
  • 둘 다 충족하지 않는 경우 또한  where절이랑 or조건 이랑 not조건을 사용해서 해결

이렇게 쿼리를 2개 만들고 조회 api도 2개로 나눠서 만들라는 의미인지 고민을 했다.

(그래서 튜터님한테 여쭤봤는데 api는 하나여야 한다고 하셨다😅) 

하지만 사실 저 조건을 충족하려면 단지 select id, content, author, created_at, updated_at from schedule 이렇게 쿼리를 날리면 되지 않나라는 고민에 휩쌓이게 됐다.

하지만 저렇게 쿼리를 날리면 수정일과 작성자명의 조건이 아예 필요 없어지는 것 같아서 일단 과제 조건에 맞게 결과를 내면서 수정일과 작성자명을 사용하는 쿼리를 입력해서 조회하는 걸로 결정했다.

@Override
    public List<ScheduleResponseDto> findScheduleByAuthorOrModifiedDate(String author, String modifiedDate) {
        return jdbcTemplate.query(
                "select id, content, author, created_at, updated_at from schedule " +
                        "where author = ? || date(updated_at) = ?  || author != ? || date(updated_at) != ? order by updated_at desc",
                scheduleRowMapper(), author, modifiedDate, author, modifiedDate);
    }

 

일정 단건 조회하기

  • Path를 사용하여 해당 id값에 해당하는 일정을 조회하도록 구현
@Override
    public Schedule findScheduleByIdOrElseThrow(Long id) {
        List<Schedule> result = jdbcTemplate.query("select * from schedule where id = ?", scheduleRowMapperV2(), id);
        return result.stream().findAny().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Does not exist id = " + id));
    }
    
  public RowMapper<Schedule> scheduleRowMapperV2(){
        return new RowMapper<Schedule>() {
            @Override
            public Schedule mapRow(ResultSet rs, int rowNum) throws SQLException {
                return new Schedule(
                        rs.getLong("id"),
                        rs.getString("content"),
                        rs.getString("author"),
                        rs.getTimestamp("created_at").toLocalDateTime(),
                        rs.getTimestamp("updated_at").toLocalDateTime()
                );
            }
        };
    }

 

일정 수정하기

  • Path로 수정할 일정의 key값을 받고, ScheduleRequestDto를 통해서 수정할 내역을 요청받음
@PatchMapping("/{id}")
    public ResponseEntity<ScheduleResponseDto> updateSchedule(@PathVariable Long id, @RequestBody ScheduleRequestDto requestDto) {
        return new ResponseEntity<>(scheduleService.updateSchedule(id, requestDto.getPassword(), requestDto.getContent(), requestDto.getAuthor()), HttpStatus.OK);
    }

 

  • key값을 통해서 수정할 엔티티의 비밀번호와 ScheduleRequestDto에서 입력받은 비밀번호와 일치하는지 확인함.
@Override
    public Schedule findSchedulePasswordByIdOrElseThrow(Long id) {
        List<Schedule> result = jdbcTemplate.query("select * from schedule where id = ?", scheduleRowMapperV3(), id);
        return result.stream().findAny().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Does not exist id = " + id));
    }
 
 public RowMapper<Schedule> scheduleRowMapperV3(){
        return new RowMapper<Schedule>() {
            @Override
            public Schedule mapRow(ResultSet rs, int rowNum) throws SQLException {
                return new Schedule(
                        rs.getLong("id"),
                        rs.getString("content"),
                        rs.getString("author"),
                        rs.getString("password"),
                        rs.getTimestamp("created_at").toLocalDateTime(),
                        rs.getTimestamp("updated_at").toLocalDateTime()
                );
            }
        };
    }

 

  • 만약 일치하지 않는다면 HttpStatus.BAD_REQUEST 로 응답
@Override
    public ScheduleResponseDto updateSchedule(Long id, String password, String content, String author) {
        // 수정할 일정의 비밀번호 조회
        Schedule schedule = scheduleRepository.findSchedulePasswordByIdOrElseThrow(id);
        if (!schedule.getPassword().equals(password)){
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다");
        }

        int updatedRow = scheduleRepository.updateSchedule(id, content, author);
        if (updatedRow == 0) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "수정된 데이터가 없습니다");
        }
        Schedule updatedSchedule = scheduleRepository.findScheduleByIdOrElseThrow(id);
        return new ScheduleResponseDto(updatedSchedule);
    }

 

일정 삭제하기

  • Path로 수정할 일정의 key값을 받아서 삭제
  • 수정 또한 update사용!
@Override
    public int deleteSchedule(Long id) {
        return jdbcTemplate.update("delete from schedule where id = ?", id);
    }

 

API 명세서 

 

DTO 목록

ScheduleSaveRequestDto

{
	"content": string,
	“author”: string,
	“password”:string,
}

 

ScheduleUpdateRequestDto

{
	"content": string,
	“author”: string,
	“password”:string,
}

 

ScheduleResponseDto

{
	"id": Long,
	"content": string,
	“author”: string,
}

 

성공

  • Status Code 201 Created

실패

  • 404 NotFound

ScheduleListResponseDto

[
	{
	"id": Long,
	"content": string,
	“author”: string
	},
	{
	"id": Long,
	"content": string,
	“author”: string
}
	// ...
]

 

성공

  • Status Code 200 OK

실패 : 조회 값이 없는 경우

  • 200 OK,비어있는 배열 응답 []

 

ERD

  • 일정 ID는 자동적으로 서버에서 고유하게 관리해 줘야 할 기본키 역할을 하기 때문에 AutoIncrement를 적용했고, 또한 데이터가 많이 쌓이게 되면 int의 범위는 너무 적다고 판단했기 때문에 bigint로 타입을 지정함

 

테이블 생성

  • 테이블 생성까지 완료! 이제 본격적으로 개발 시작!

DTO?

Data Transfer Object, 즉, HTTP 메시지를 전달하고 스프링에서 계층간의 데이터를 전달할 때 사용하는 객체 

DTO의 사용 이유

1. Entity와 DTO의 책임을 명확하게 분리하기 위함

  • Entity는 ORM을 해주기 위한 객체
  • DTO는 계층간의 데이터를 전송해 주기 위한 객체 & Client에게 Entity정보를 숨기기 위함 

-> DTO 대신 Entity를 사용하게 된다면 Entity가 가지고 있는 민감한 정보(주민번호, 비밀번호,,)가 노출 될 수 있음

 

2. DB 구조가 변경됐을 때 코드의 유지보수에 용이하기 위함

 

예시

MemberController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/member")
public class MemberController {

    private final MemberService memberService;

    @GetMapping("/{memberId}")
    public MemberResponseDto getMemberInfo(
            @PathVariable Long memberId
    ) {
        return memberService.getMemberInfo(memberId);
    }
}

 

MemberService

MemberService
@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    public MemberResponseDto getMemberInfo(Long memberId) {
        Member member = memberRepository.findById(memberId).orElseThrow(NullPointerException::new);
        // Entity To DTO
        MemberResponseDto responseDto = new MemberResponseDto(member);
        return responseDto;
    }
}

 

MemberResponseDto

@Getter
public class MemberResponseDto {
    private String username;
    private int age;

    public MemberResponseDto(Member member) {
        this.username = member.getUsername();
        this.age = member.getAge();
    }
}

 

Member & MemberRepository

@Getter
@Entity
@NoArgsConstructor
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private int age;
    private String rrn; // 주민번호, Resident Registration Number
}

public interface MemberRepository extends JpaRepository<Member, Long> {}

+ Recent posts