인프런 워밍업 클럽 스터디(BE) 0기 / 2주차 발자국

인프런 워밍업 클럽 스터디(BE) 0기 / 2주차 발자국

일주일 간의 학습 내용에 대한 간단한 회고📕

지난 1주차 회고에서는 10000자 되는 내용으로 깊게 정리했습니다.😅 2주차는 1주차처럼 모든 내용을 담은 정리하지 않고, '핵심 포인트' 위주로 필요하다고 생각되는 부분의 요약과 함께 내용을 정리하는 2주차 회고를 작성합니다😀 (10000자 -> 4000자)

 

🔥무엇보다 학습 내용 범위 벗어난 스펙과 기술을 사용하지 않는 것을 원칙으로 학습하고 있습니다. 처음 시작할 때의 열정과 목표를 잊지 않으면서, 강의를 통해 멘토님께서 전달하고자 하는 지혜와 경험을 쌓아가며 <미니 프로젝트> 의 모든 단계를 성공적으로 마무리할 수 있도록 성장하기를 나, 스스로에게 응원합니다.

  • 일주일 동안 스스로 칭찬하고 싶은 점
    하루도 빠지지 않고 열심히 학습하고 달려왔고, 어떻게 해야 좀 더 효과적인 학습을 할 수 있을까에 대해 고민하고 행동으로 실천하여 효율적인 학습을 할 수 있었습니다.

  • 아쉬웠던 점
    감기에 걸려서 제대로 학습하지 못한 날이 꽤 있었습니다. 아파서 학습하지 못한 이 공백을 채우기 위해 더욱 집중력 있게 학습할 수 있도록 노력해야 겠다고 생각했습니다.

  • 보완하고 싶은 점
    2 주차는 깊게 정리하지 않고 유연하게 정리하면서 멘토님의 PPT와 PDF 를 중심으로 학습하면서 훨씬 효율적인 복습을 했습니다. 복습하면서 아직은 완벽하게 체득하지 못한 부분들이 꽤 있다는 것을 <미니 프로젝트>를 진행하면서 느끼게 됐습니다.

  • 다음주에는 어떤 식으로 학습하겠다는 스스로의 목표
    개인적으로 진행하고 있는 프로젝트와 학습하는 것의 비중을 낮추고, <미니 프로젝트> 를 중심으로 부족한 부분들에 대한 이론과 예제를 개인적으로 정리하면서 진행하려고 합니다. 기회가 된다면 <미니 프로젝트> HTML이라도 구현하는 것을 목표로 하고 있습니다.

     

 


학습 내용 정리

19강. UserController와 스프링 컨테이너

@RestController는 컨트롤러 클래스를 API 진입 지점으로 만들어주고 스프링 빈으로 등록시킵니다. 스프링 빈은 스프링 컨테이너에 들어간 클래스를 의미합니다. 스프링 빈에 등록된 클래스들을 식별하기 위해 이름 및 타입과 함께 다양한 정보가 저장되며 인스턴스화를 수행합니다. 그리고 JdbcTemplate 역시 스프링 빈으로 등록되어 있습니다. build.gradle 안의 spring-boot-starter-data-jpa 의존성에 의해JdbcTemplate 을 스프링 빈으로 미리 등록됩니다.

따라서 스프링 컨테이너는 UserController 를 인스턴스화할 때, UserController에서 필요한 JdbcTemplate을 스프링 컨테이너 내부에서 찾아 인스턴스화를 진행하게 됩니다. 따라서 JdbcTemplate을 스프링 빈으로 등록하는 의존성인 spring-boot-starter-data-jpa 이 없으면 에러가 발생한다는 점을 참고합니다.

스프링 부트 서버를 실행하면 다음과 같은 일이 순차적으로 내부에서 실행됩니다.

  1. 스프링 컨테이너가 시작합니다.

  2. 스프링 컨테이너에 기본적으로 많은 스프링 빈이 등록됩니다.( JdbcTemplate이 등록됩니다.)

  3. 개발자가 작성한 스프링 빈이 등록됩니다.( UserController 가 등록됩니다.)

  4. 필요한 의존성이 자동으로 설정됩니다.( UserController 를 만들 때 JdbcTemplate 을 알아서 넣어줍니다.)

이제 지금까지 UserRepositoryJdbcTemplate 을 바로 가져오지 못하는 이유를 알 수 있습니다. UserController@RestController 에 의해 스프링 빈에 등록하고 동일한 스프링 빈인 JdbcTemplate 을 가져올 수 있지만, UserRepository 는 스프링 빈이 아니기 때문에 가져올 수 없습니다. 따라서 서비스와 리포지토리를 스프링 컨테이너에 등록하기 위해 @Service, @Repository어노테이션을 사용해야 합니다.

정리하자면 UserController - UserService - UserRepository 클래스는 서버가 시작할 때 다음과 같이 수행됩니다.

  1. JdbcTemplate 을 이용해 UserRepository 가 스프링 빈으로 등록됩니다. (인스턴스화를 수행합니다.)

  2. UserRepository 를 의존하는 UserService 가 스프링 빈으로 등록됩니다.

  3. UserService 를 의존하는 UserController 가 스프링 빈으로 등록됩니다.

  4. 이렇게 3개의 클래스 모두 스프링 빈으로 등록됩니다!


20강. 스프링 컨테이너를 왜 사용할까?!

MySQL 을 사용하여 데이터를 저장하는 방식으로 변경하면 다음과 같은 일이 발생됩니다.

  1. BookMemoryRepository 대신하는 BookMySqlRepository 를 생성합니다. JdbcTemplate 을 생성자로 받을 수도 있지만, BookMySqlRepository 가 직접 설정해 준다고 가정합니다.

  2. BookService 도 변경됩니다. BookMemoryRepository()대신 BookMySqlRepository() 를 사용합니다.

서비스까지 변경되는 것이 바로 가장 큰 문제입니다. 데이터를 메모리에 저장할지 MySQL에 저장할지에 대해서만 변경하고 싶지만, BookService까지 필연적으로 변경이 일어나게 됩니다. 이 고민에 대한 해결책이 바로 스프링 컨테이너입니다.

스프링 컨테이너를 사용한다고 가정합니다.

  • 스프링 컨테이너는 BookMemoryRepository 혹은 BookMySqlRepository 중 하나를 선택한 후, BookService 를 생성합니다. 이런 방식을 어려운 말로 제어의 역전 (IoC, Inversion of Control)이라 부릅니다.

  • 또한 컨테이너가 BookService 를 생성할 때 BookMemoryRepositoryBookMySqlRepository 중 하나를 선택해서 넣어주는 과정을 의존성 주입(Dependency Injection)이라고 합니다.

@Service
public class BookService {
    private final BookRepository bookRepository;
    public BookService(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }
}
public interface BookRepository {
    public void save(String bookName);
}
@Repository
public class BookMemoryRepository implements BookRepository {
    @Override
    public void save(String bookName) {
        println("Memory Repository " + bookName);
    }
}
@Repository
@Primary // 우선권을 부여하는 어노테이션!!
public class BookMySqlRepository implements BookRepository {
    @Override
    public void save(String bookName) {
        println("MySQL Repository " + bookName);
        }
}

스프링 컨테이너에 BookMemoryRepository 혹은 BookMySqlRepository 둘 중 어느 것을 등록할 지에 대해서는@Primary 어노테이션을 이용해 우선권을 제어할 수 있습니다.


21강. 스프링 컨테이너를 다루는 방법

서비스와 리포지토리 클래스를 @Service, @Repository 어노테이션으로 스프링 빈으로 등록했습니다. 이 방식 뿐만 아니라 다른 어노테이션으로도 스프링 빈에 등록할 수 있습니다.

  • @Configuration : 클래스에 붙이는 어노테이션. @Bean을 사용할 때 함께 사용해 주어야 합니다.

  • @Bean : 메서드에 붙이는 어노테이션. 메서드에서 반환되는 객체를 스프링 빈에 등록합니다.

다음 예제는 UserRepository@Configuration@Bean 을 활용한 예제입니다.

@Configuration
public class UserConfiguration {
    @Bean
    public UserRepository userRepository(JdbcTemplate jdbcTemplate) {
        return new UserRepository(jdbcTemplate);
    }
    
    @Bean
    public UserService userService(UserRepository userRepository) {
        return new UserService(userRepository);
    }
}

그렇다면 언제 @Service, @Repository 를 사용해야 하고, 언제 @Configuration + @Bean 을 사용해야 할까요? 정답은 없습니다!

  • 일반적으로 개발자가 직접 만든 클래스를 스프링 빈으로 등록할 때에는 @Service, @Repository 를 사용합니다.

  • 외부 라이브러리, 프레임워크의 생성된 클래스를 스프링 빈으로 등록할 때 @Configuration + @Bean 조합을 많이 사용하게 됩니다.

@Component 어노테이션은 @RestController, @Service, @Repository, @Configuration 모두 가지고 있습니다. @Component 어노테이션을 붙이면 주어진 클래스를 ‘컴포넌트'로 간주하고, 컴포넌트들은 스프링 스프링 서버가 뜰 때 자동으로 감지됩니다. @Component 덕분에 지금까지 우리가 사용했던 어노테이션들이 모두 자동으로 감지된 것입니다.

@Component어노테이션은 컨트롤러, 서비스, 리포지토리가 아니라 추가적인 클래스를 스프링 빈으로 등록할 때 종종 사용됩니다.

스프링 빈으로 등록하는 방법을 살펴보았으니, 스프링 빈을 주입받는 방법은 다음과 같습니다. 가장 간단하고 권장되는 방법은 생성자를 이용해 주입받는 방법입니다. 지금까지 우리가 계속 사용한 방법입니다.

@Repository
public class UserRepository {
    private final JdbcTemplate jdbcTemplate;
    
    // 생성자에 JdbcTemplate이 있으므로 스프링 컨테이너가 넣어준다.
    public UserRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
}

두 번째 방법은 setter 주입 방식입니다. final 키워드를 제거하고 setter 메서드에 @Autowired 어노테이션을 작성해야 합니다.

@Repository
public class UserRepository {
    private JdbcTemplate jdbcTemplate; // 1. final 제거
    
    @Autowired // 2. @Autowired 추가
    public void setJdbcTemplate(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
}

@Autowired 어노테이션이 있어야 스프링 컨테이너에 있는 스프링 빈을 찾아 setter 메서드에 넣어 주게 됩니다.

세 번째 방법은 필드에 직접적으로 주입하는 방법입니다. 필드 위에 @Autowired 어노테이션을 작성합니다.

@Repository
public class UserRepository {
    @Autowired // 필드에 @Autowired 추가
    private JdbcTemplate jdbcTemplate;
}

setter 주입 방식과 필드에 바로 주입하는 방법은 기본적으로 권장되지 않습니다.

  • setter를 사용하게 되면 혹시 누군가가 setter를 사용해 다른 인스턴스로 교체해 동작에 문제 가 생길 수도 있고,

  • 필드에 바로 주입하게 되면 테스트가 어렵기 때문입니다.

@Qualifier어노테이션은 @Primary 어노테이션이 없는 경우에 주입받는 쪽에서 특정 스프링 빈을 선택할 수 있습니다.

public interface FruitService {} // 과일 인터페이스
@Service
public class AppleService {} // 사과 클래스
@Service
public class BananaService {} // 바나나 클래스
@Service
public class OrangeService {} // 오렌지 클래스
@RestController
public class UserController {
    private final UserService userService;
    private final FruitService fruitService;
    
    public UserController(
        UserService userService,
        @Qualifier("appleService") FruitService fruitService) {
            this.userService = userService;
            this.fruitService = fruitService;
        }
}

@Qualifier("appleService") FruitService fruitService)에 의해 FruitService 에는 AppleService 가 들어오게 됩니다.

image

@Qualifier 어노테이션은 스프링 빈을 사용하는 쪽과 스프링 빈을 등록하는 쪽 모두 사용할 수 있습니다. 이 경우에는 @Qualifier 어노테이션에 적어준 값이 같은 것끼리 연결됩니다.

@Service
@Qualifier("main")
public class BananaService {}
@RestController
public class UserController {
    private final UserService userService;
    private final FruitService fruitService;
    public UserController(
        UserService userService,
        @Qualifier("main") FruitService fruitService) {
            this.userService = userService;
            this.fruitService = fruitService;
        }
}

image

만약 @Primary@Qualifier 를 둘 다 사용하고 있으면 @Qualifier 의 우선 순위가 높습니다. 왜냐하면 스프링 빈을 사용하는 쪽에서 특정 빈을 지정해 준 것이 더욱 우선 순위를 높게 간주합니다.


22강. Section3 정리

  1. 좋은 코드가 왜 중요한지 이해하고, 원래 있던 Controller 코드를 보다 좋은 코드로 리팩 토링한다.

  2. 스프링 컨테이너와 스프링 빈이 무엇인지 이해한다.

  3. 스프링 컨테이너가 왜 필요한지, 좋은 코드와 어떻게 연관이 있는지 이해한다.

  4. 스프링 빈을 다루는 여러 방법을 이해한다.


23강. 문자열 SQL을 직접 사용하는 것이 너무 어렵다!!

SQL을 직접 작성해 개발하게 되면서 '컴파일 타임 에러 체크 불가능', '특정 데이터베이스에 종속', '수많은 반복 작업', '데이터베이스 테이블과 객체의 패러다임' 등 이러한 어려움이 있었습니다. 그래서 사람들은 JPA를 만들게 되었습니다. JPA란 Java Persistence API의 약자로 자바 진영의 ORM(Object-Relational Mapping) 기술 표준을 의미합니다.

  • 영속성(Persistence)은 데이터를 생성한 프로그램이 종료되더라도, 그 데이터는 영구적인 속성을 갖는 것을 의미합니다.

  • API는 우리가 만든 HTTP API에서도 쓰였지만, ‘정해진 규칙’을 의미합니다.

그럼 여기까지 정리해 보면, JPA는 데이터를 영구적으로 보관하기 위해 Java 진영에서 정해진 규칙입니다. 이제 ORM(Object-Relational Mapping)에 대해 이해합니다.

  • Object 단어는 우리가 Java에서 사용하는 ‘객체’와 동일합니다.

  • Relational 의미는 관계형 데이터베이스의 ‘테이블’을 의미합니다.

  • Mapping이라는 의미는 말 그대로 둘을 짝지어 준다는 의미입니다.

여기까지 정리하면 JPA란 다음과 같이 이해할 수 있습니다. 🔥 객체와 관계형 데이터베이스의 테이블을 짝지어 데이터를 영구적으로 저장할 수 있도록 정해진 Java 진영의 규칙

JPA(ORM)는 규칙(Interface)이기 때문에 구현체가 필요합니다. 따라서 JPA 를 실제 코드로 작성한 가장 유명한 프레임워크가 바로 Hibernate 가 있습니다. Hibernate은 내부적 으로 JDBC를 사용하고 있습니다. 그림으로 나타내면 다음과 같습니다.

image


24강. 유저 테이블에 대응되는 Entity Class 만들기

User 객체에 @Entity 어노테이션을 작성합니다. Entity는 ‘저장되고, 관리되어야 하는 데이터’를 의미합니다. 어노테이션은 마법 같은 일을 해준다고 했습니다. @Entity 를 붙이게 되면, 스프링이 @Entity 인식하여 서버가 동작하면 User 객체와 user 테이블을 같은 것으로 간주합니다.

user 테이블에만 존재하는 idUser 객체에 추가합니다.id는 테이블에서 primary key 를 의미합니다.

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id = null;
    
    // 생략...
}
  • @Id : 이 필드를 primary key로 간주한다.

  • @GeneratedValue : primary key는 DB에서 자동 생성해 주기 때문에 이 어노테이션을 붙여주어야 합니다. DB의 종류마다 자동 생성 전략이 다른데, MySQL의 경우 auto_increment 를 사용합니다. 이 전략은 IDENTITY 전략과 매칭됩니다.

JPA에 의해 테이블과 매핑된 객체는 파라미터를 가지지 않은 기본 생성자가 꼭 필요합니다. 현재는 User(String name, Integer age) 파라미터를 2개 가진 생성자만 있기 때문에 에러가 발생합니다. 기본 생성자도 추가할 때 protected 해도 괜찮습니다.

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id = null;
    
    @Column(nullable = false, length = 20, name = "name")
    private String name;
    
    private Integer age;
    
    protected User() { }
    // 생략...
}

Column에 대해 @Column 어노테이션으로 다양한 옵션을 설정할 수 있습니다. 주로 필드에 null이 들어갈 수 있는지의 여부, 길이 제한, DB에서의 column 이름을 설정합니다. 지금은 User 객체와 user 테이블의 필드 이름이 같지만, 다를 경우 @Column 어노테이션을 통해 설정해 주면 됩니다.

@Column 어노테이션이 존재하지 않는 필드이더라도 JPA는 해당 필드가 Table에도 있을 거라 생각합니다. 예를 들어 private Integer age 라는 필드는 자동으로 user 테이블의 age 와 매핑하게 됩니다.

이제 최초 JPA를 적용할 때 설정해 주는 옵션을 추가합니다. application.yml 파일을 찾은 다음과 같이 입력합니다.

spring:
  datasource:
    url: "jdbc:mysql://localhost/library"
    username: "root"
    password: ""
    driver-class-name: com.mysql.cj.jdbc.Driver
### 아래 부분이 추가되었다!!! ###
  jpa:
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        dialect: org.hibernate.dialect.MySQL8Dialect
  • spring.jpa.hibernate.ddl-auto

    스프링이 시작할 때 DB에 있는 테이블을 어떻게 처리할지에 대한 옵션.

    1. create : 기존 테이블이 있다면 삭제 후 다시 생성.

    2. create-drop : 스프링이 종료될 때 테이블을 삭제.

    3. update : 객체와 테이블이 다른 부분만 변경.

    4. validate : 객체와 테이블이 동일한지 확인.

    5. none : 별다른 조치를 하지 않음.

    현재 우리는 DB에 테이블이 잘 만들어져 있고, 미리 넣어둔 데이터도 있으므로 none 이라 설정합니다.

  • spring.jpa.properties.hibernate.show_sql : JPA를 사용해 DB에 SQL을 날릴 때 SQL을 보여줄지 결정. (true)

  • spring.jpa.properties.hibernate.format_sql : JPA를 사용해 DB에 SQL을 날릴 때 SQL을 예쁘게 포맷팅할지 결정. (true)

  • spring.jpa.properties.hibernate.dialect dialect : 한국어로 방언, 사투리라는 의미. 이 옵션을 통해 JPA가 알아서 Database끼리 다른 SQL을 조금씩 수정합니다. 우리는 MySQL 8버전을 사용하고 있으므로org.hibernate.dialect.MySQL8Dialect 로 설정하면 됩니다.

엔티티 생성과 application.yml 설정으로 객체와 테이블 간의 매핑을 모두 마쳤습니다. 다음 시간에 SQL 을 직접 작성하지 않고 DB에 쿼리를 수행합니다.


25강. Spring Data JPA를 이용해 자동으로 쿼리 날리기

SQL을 작성하지 않고 유저 테이블에 쿼리를 수행합니다. 유저 생성 / 조회 / 업데이트 기능을 리팩토링합니다.

  1. User 도메인 객체와 같은 위치에 UserRepository 라는 인터페이스를 생성.

public interface UserRepository {}

  1. JpaRepository 를 상속. ( 매핑 객체인 User 와 유저 테이블의 idLong 타입을 작성)

public interface UserRepository extends JpaRepository<User, Long> {}

UserService 에서 직접 SQL 작성을 작성한 UserRepository 대신 새로운 UserRepository 를 사용합니다. 가장 먼저 UserService 의 저장 기능부터 변경합니다.

// JDBC 구현
public void saveUser(UserCreateRequest request) {
    userJdbcRepository.saveUser(request.getName(), request.getAge());
}
​
// Spring Data JPA 구현
public void saveUser(UserCreateRequest request) {
    userRepository.save(new User(request.getName(), request.getAge()));
}
​
// Spring Data JPA 구현 - id 출력하기
public void saveUser(UserCreateRequest request) {
    User user = userRepository.save(new User(request.getName(), request.getAge()));
    System.out.println(user.getId());
}

조회 기능도 변경을 변경합니다.

// JDBC 구현
public List<UserResponse> getUsers() {
    return userJdbcRepository.getUserResponses();
}
// Spring Data JPA 구현
public List<UserResponse> getUsers() {
    return userRepository.findAll().stream()
        .map(user -> new UserResponse(user.getId(), user.getName(), user.getAge()))
        .collect(Collectors.toList());
}

findAll 메서드는 모든 유저 데이터를 조회하는 SQL이 수행되며 그 결과는 List 로 반환됩니다. ListUserResponse 으로 전달합니다. 만약 UserResponse 에서 User 를 받는 생성자를 작성하면 코드를 더욱 깔끔하게 변경할 수 있습니다.

public List<UserResponse> getUsers() {
    return userRepository.findAll().stream()
        .map(UserResponse::new)
        .collect(Collectors.toList());
}

다음으로는 업데이트 기능을 변경합니다.업데이트에서는 2번의 쿼리를 사용합니다.

  1. id를 통해 User를 가져와 User가 있는지 없는지 확인하고,

  2. User가 있다면 update 쿼리를 날려 데이터를 수정.

// JDBC 구현
public void updateUser(UserUpdateRequest request) {
    if (userJdbcRepository.isUserNotExist(request.getId())) {
        throw new IllegalArgumentException();
    }
    userJdbcRepository.updateUserName(request.getName(), request.getId());
}
// Spring Data JPA 구현
public void updateUser(UserUpdateRequest request) {
    User user = userRepository.findById(request.getId())
        .orElseThrow(IllegalArgumentException::new);
    user.updateName(request.getName());
    userRepository.save(user);
}

findByIdid에 해당하는 1개의 데이터를 가져올 수 있습니다. 이때 Java 라이브러리의 Optional이 반환되는데, orElseThrow 를 사용하면 User가 비어있는 경우 에러를 던집니다. 반환된 User 객체의 이름을 업데이트해주고, 위에서 사용했던 save 기능을 호출하면 됩니다.

setter 대신 updateName 으로 명시적인 이름을 붙여준 이유는 다음 링크 영상에서 참고할 수 있습니다.

지금까지 사용한 기능은 다음과 같습니다.

  1. save : 주어지는 객체를 저장하거나 업데이트.

  2. findAll : 주어지는 객체가 매핑된 테이블의 모든 데이터를 가져옴.

  3. findById : id를 기준으로 특정한 1개의 데이터를 가져옴.

그런데 한 가지 궁금한 점이 있습니다. 어떻게 SQL을 작성하지 않아도 쿼리가 나갈 수 있을까요? 객체와 테이블을 자동으로 매핑해 준 JPA가 처리해 준 것일까요? 정답은, 비슷하지만 조금 다릅니다. JPA를 이용하는 Spring Data JPA 가 자동으로 처리해준 것입니다. 23강에서 확인했던 JPA, Hibernate, JDBC 관계에 Spring Data JPA를 추가해 보면 다음과 같습니다.

사용한 save, findAll 같은 메소드는 SimpleJpaRepository 에서 찾아볼 수 있습니다. 스프링을 시작하면 여러가지 설정을 해준다고 했는데, 스프링은 JpaRepository 를 구현 받는 리포지토리에 대해 자동으로 SimpleJpaRepository 기능을 사용할 수 있도록 합니다. SimpleJpaRepository 코드를 열어보면, 조금 복잡한 코드들을 확인할 수 있는데, 이게 바로 JPA 코드입니다. Spring Data JPA 를 사용하는 덕분에 복잡한 JPA 코드를 직접 사용하는 게 아니라, 추상화된 기능으로써 사용할 수 있습니다.

이를 그림으로 표현해 보면 다음과 같습니다.

image


26강. Spring Data JPA를 이용해 다양한 쿼리 작성하기

유저 삭제 기능을 구현해 보고, Spring Data JPA를 이용한 다양한 조회 쿼리 작성 방법을 학습합니다.

// JDBC 구현
public void deleteUser(String name) {
    if (userJdbcRepository.isUserNotExist(name)) {
        throw new IllegalArgumentException();
    }
    userJdbcRepository.deleteUserByName(name);
}   

이름을 통해 유저 여부를 확인하고 delete 쿼리를 수행합니다. UserRepository 인터페이스에서 다음과 같은 메소드 시그니처를 작성합니다.

public interface UserRepository extends JpaRepository<User, Long> {
    User findByName(String name);
}
  • User : 이름을 기준으로 유저 데이터를 조회해 유저 객체를 반환(유저 정보가 없다면, null 반환)

  • findByName

    • 함수 이름으로 알아서 SQL 조립

    • find는 1개의 데이터를 가져옴.

    • By 뒤에 붙는 필드 이름으로 SELECT 쿼리의 WHERE 문이 작성됨.

    • 예를 들어, findByNameselect * from user where name = ? 과 동일.

findByName(String name) 을 통해 이름을 기준으로 User 정보를 가져올 수 있습니다. UserRepository에서 기본으로 제공되는 delete 메소드를 사용합니다.

public void deleteUser(String name) {
    User user = userRepository.findByName(name);
    if (user == null) {
        throw new IllegalArgumentException();
    }
    userRepository.delete(user);
}

UserController에서 UserServiceV2으로 변경하고 테스트를 수행합니다. UserService 인터페이스를 생성하여 다형성을 이용할 수도 있지만, 간단한 작업이므로 객체 타입 전체를 변경합니다.

@RestController
public class UserController {
    // UserServiceV2를 사용하도록 변경
    private final UserServiceV2 userServiceV2;
    
    public UserController(UserServiceV2 userServiceV2) {
        this.userServiceV2 = userServiceV2;
    }
}

생성 / 조회 / 업데이트 / 삭제 기능까지 모두 JDBC 대신 Spring Data JPA를 사용해 잘 동작하는 것을 확인할 수 있습니다. Spring Data JPA의 추가적인 쿼리 작성법에 대해 학습합니다.

By 앞에는 다음과 같은 구절이 들어갈 수 있습니다.

  • find : 반환 타입은 객체가 될 수도 있고, Optional<타입> 이 될 수도 있음.

  • findAll : 쿼리의 결과물이 N개인 경우 사용. 반환 타입은 List<타입>.

  • exists : 쿼리 결과가 존재하는지를 확인. 반환 타입은 boolean.

  • count : SQL의 결과 개수 반환. 반환 타입은 long.

By 뒤에는 필드 이름이 들어갑니다. 또한 이 필드들은 AndOr 로 조합될 수 있습니다.

  1. findAllByNameAndAge 작성하게 되면, select * from user name = ? and age = ? 쿼리가 수행됩니다.

  2. findAllByNameOrAge 작성하게 되면, select * from user name = ? or age = ? 쿼리가 수행됩니다.

동등 조건 ( = ) 외에 다양한 조건을 활용할 수도 있습니다. 크다 작다를 사용할 수도 있고, 사이에 있는지 확인할 수도 있습니다. 또한 특정 문자열로 시작하는지 끝나는지 확인할 수도 있습니다.

  • GreaterThan : 초과

  • GreaterThanEqual : 이상

  • LessThan : 미만

  • LessThanEqual : 이하

  • Between : 사이

  • StartsWith : ~로 시작하는

  • EndsWith : ~로 끝나는

예를 들어 특정 나이 사이의 유저를 검색하고 싶다면, 다음과 같은 함수를 만들 수 있습니다.

public interface UserRepository extends JpaRepository<User, Long> {
    List<User> findAllByAgeBetween(int startAge, int endAge);
}

JPA와 Spring Data JPA를 활용하여 SQL을 직접 사용해야 하는 아쉬움을 해결 했습니다. 하지만 아직 Service 계층의 역할이 남아 있습니다. 서비스 계층의 중요한 역할은 바로 ‘트랜잭션’ 관리이다. 다음 시간에는 트랜잭션이 무엇인지 그리고 왜 필요한지 알아보도록 하자.


27강. 트랜잭션 이론편

트랜잭션이란 여러 SQL을 사용해야 할 때 한 번에 성공시키거나, 하나라도 실패하면 모두 실패시키는 기능입니다. 그래서 트랜잭션을 ‘쪼갤 수 없는 업무의 최소 단위’라고 표현합니다. 트랜잭션을 시작하고 사용한 SQL을 모두 취소하고 싶다면, commit 대신 rollback 이라는 명령어를 사용하면 됩니다.

다음 시간에는 트랜잭션을 어떻게 적용할 수 있을지 알아보도록 합니다.


28강. 트랜잭션 적용과 영속성 컨텍스트

트랜잭션을 UserService 에 적용하고 JPA에 등장하는 영속성 컨텍스트라는 개념에 대해 학습합니다.

지난 시간에 살펴보았던 것처럼, 우리가 원하는 것은

  1. 서비스 메소드가 시작할 때 트랜잭션이 시작되어,

  2. 서비스 메소드 로직이 모두 정상적으로 성공하면 commit 되고,

  3. 서비스 메소드 로직 실행 도중 문제가 생기면 rollback 되는 것 입니다.

트랜잭션을 적용하는 방법은 매우 간단합니다! 대상 메소드에 @Transactional 어노테이션을 붙여주기만 하면 됩니다. 주의할 점으로는 org.springframework.transaction.annotation.Transactional 을 붙여야 합니다. 다른 패키지의 @Transactional 을 붙이면 정상 동작하지 않을 수 있습니다.

@Transactional
public void saveUser(UserCreateRequest request) {
    userRepository.save(new User(request.getName(), request.getAge()));
}
@Transactional
public void updateUser(UserUpdateRequest request) {
    User user = userRepository.findById(request.getId())
        .orElseThrow(IllegalArgumentException::new);
    user.updateName(request.getName());
    userRepository.save(user);
}
public void deleteUser(String name) {
    User user = userRepository.findByName(name);
    if (user == null) {
        throw new IllegalArgumentException();
    }
    userRepository.delete(user);
}
@Transactional(readOnly = true)
    public List<UserResponse> getUsers() {
        return userRepository.findAll().stream()
            .map(UserResponse::new)
            .collect(Collectors.toList());
}

데이터의 변경이 없고, 조회 기능만 있을 때는 readOnly 옵션을 줄 수 있습니다.

  • @Transactional(readOnly = true)

트랜잭션 적용이 성공적으로 모두 잘 됐는지 테스트를 수행합니다.

@Transactional
public void saveUser(UserCreateRequest request) {
    userRepository.save(new User(request.getName(), request.getAge()));
    throw new IllegalArgumentException();
}

@Transactional 어노테이션에 대해 한 가지 알아두어야 할 점은, Unchecked Exception에 대해서만 롤백이 일어난다는 점입니다. IOException과 같은 Checked Exception에서는 롤백 이 일어나지 않습니다.

영속성 컨텍스트란, 테이블과 매핑된 Entity 객체를 관리/보관하는 역할을 수행합니다. 스프링에서는 트랜잭션을 사용하면 영속성 컨텍스트가 생겨 나고, 트랜잭션이 종료되면 영속성 컨텍스트가 종료됩니다. 또한, 영속성 컨텍스트는 특별한 능력을 4가지 가지고 있습니다.

  1. 변경 감지 (Dirty Check) 영속성 컨텍스트에 등록된 Entity는 명시적으로 save 를 해주지 않더라도 알아서 변경을 감지하여 저장

  2. 쓰기 지연 영속성 컨텍스트에 의해 트랜잭션이 commit 되는 시점에 SQL을 모아서 한 번만 쿼리를 수행( update, delete 동일)

  3. 1차 캐싱 ID를 기준으로 Entity를 기억하는 기능으로 영속성 컨텍스트가 보관하고 있는 데이터를 활용


29강. Section 4 정리. 다음으로!

문자열 SQL로 구성했던 우리의 데이터 접근 기술을 객체 지 향 프로그래밍이 가능하도록 JPA를 활용해 완전히 변경했습니다. 이 과정에서 아래의 내용들을 익힐 수 있었습니다.

  1. 문자열 SQL을 직접 사용하는 것의 한계를 이해하고, 해결책인 JPA, Hibernate, Spring Data JPA가 무엇인지 이해한다.

  2. Spring Data JPA를 이용해 데이터를 생성, 조회, 수정, 삭제할 수 있다.

  3. 트랜잭션이 왜 필요한지 이해하고, 스프링에서 트랜잭션을 제어하는 방법을 익힌다.

  4. 영속성 컨텍스트와 트랜잭션의 관계를 이해하고, 영속성 컨텍스트의 특징을 알아본다.


30강. 책 생성 API 개발하기

먼저 요구사항을 살펴봅니다.

  • 도서관에 책을 등록할 수 있다.

다음으로 API 스펙을 확인합니다.

  • HTTP Method : POST

  • HTTP Path : /book

  • HTTP Body (JSON)

{
"name": String // 책 이름
}
  • 결과 반환 X (HTTP 상태 200 OK이면 충분합니다.)

book 테이블을 설계하고, Book 객체를 만들고, Repository, Service, Controller, DTO를 만들어 주면 됩니다. 꼭 이 순서로 진행해야 하는 것은 아닙니다. 작업하다 보면 익숙한 순서가 생기게 됩니다.

테이블

create table book(
    id bigint auto_increment,
    name varchar(255),
    primary key (id)
);

엔티티

@Entity
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id = null;
    
    @Column(nullable = false)
    private String name;
}

리포지토리

public interface BookRepository extends JpaRepository<Book, Long> {}

RookCreateRequest

public class BookCreateRequest {
    private String name;
    public String getName() {
        return name;
    }
}

BookController

@RestController
public class BookController {
    
    private final BookService bookService;
    
    public BookController(BookService bookService) {
        this.bookService = bookService;
    }
    
    @PostMapping("/book")
    public void saveBook(@RequestBody BookCreateRequest request) {
        bookService.saveBook(request);
    }
}
​
@Service
public class BookService {
    
    private final BookRepository bookRepository;
    
    public BookService(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }
​
    @Transactional
    public void saveBook(BookCreateRequest request) {
        bookRepository.save(new Book(request.getName()));
    }
}
@Entity
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id = null;
    
    @Column(nullable = false)
    private String name;
    
    // Book.java 안에 추가된 로직
    protected Book() { }
​
    public Book(String name) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException(String.format("잘못된 name(%s)이 들어왔습니다", name));
        }
        this.name = name;
    }
}

이 과정에서 필요한 Book의 생성자가 자연스럽게 생성됩니다. 다음 시간에는 이어서 대출 기능을 구현합니다.


31강. 대출 기능 개발하기

요구사항

  • 사용자가 책을 빌릴 수 있다.

    • 다른 사람이 그 책을 진작 빌렸다면 빌릴 수 없다.

API 스펙

  • HTTP Method : POST

  • HTTP Path : /book/loan

  • HTTP Body (JSON)

{
    "userName": String
    "bookName": String
}
  • 결과 반환 X (HTTP 상태 200 OK이면 충분합니다.)

테이블

create table user_loan_history (
    id bigint auto_increment,
    user_id bigint,
    book_name varchar(255),
    is_return tinyint(1),
    primary key (id)
)

엔티티

@Entity
public class UserLoanHistory {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;
    private long userId;
    private String bookName;
    private boolean isReturn;
}

is_return 필드는 tinyint입니다. 이를 boolean에 메핑하게 되면 true인 경우 1, false인 경우 0이 저장됩니다.

리포지토리

public interface UserLoanHistoryRepository extends JpaRepository<UserLoanHistory, Long> {}

BookLoanRequest DTO

// DTO
public class BookLoanRequest {
    private String userName;
    private String bookName;
    
    public String getUserName() {
        return userName;
    }
    
    public String getBookName() {
        return bookName;
    }
}

컨트롤러 - loanBook 메서드 추가

// Controller (BookController.java)
@PostMapping("/book/loan")
    public void loanBook(@RequestBody BookLoanRequest request) {
bookService.loanBook(request);
}

서비스

@Transactional
public void loanBook(BookLoanRequest request) {}

우선은 책 객체를 이름을 가져옵니다. 만약 책이 없는 경우에는 예외를 던져주어야 합니다. 이름을 기준으로 책을 가져오려면, BookRepository 에 메소드 시그니처 작성도 필요합니다.

// Repository
public interface BookRepository extends JpaRepository<Book, Long> {
    Optional<Book> findByName(String bookName);
}
​
// Service
@Transactional
public void loanBook(BookLoanRequest request) {
    Book book = bookRepository.findByName(request.getBookName())
        .orElseThrow(IllegalArgumentException::new);
}

Book 객체를 가져왔다면, DB에 존재하는 책입니다. 그리고 Book 객체의 책이 누군가 대출 중인지 확인합니다. 이번에는 UserLoanHistoryRepository 에 메소드 시그니처 작성이 필요합니다.

public interface UserLoanHistoryRepository extends JpaRepository<UserLoanHistory, Long> {
    boolean existsByBookNameAndIsReturn(String bookName, boolean isReturn);
}

existsByBookNameAndIsReturn의 매개변수로 책 이름과 false 를 넣은 값이 true가 나왔다는 의미는 현재 반납되지 않은 대출 기록이 있다는 의미이니 누군가 대출했다는 의미입니다. 따라서 Service는 다음과 같이 변경됩니다.

@Service
public class BookService {
    
    private final BookRepository bookRepository;
    
    // UserLoanHistoryRepository에 접근해야 하니 의존성을 추가해주었다!
    private final UserLoanHistoryRepository userLoanHistoryRepository;
    
    // 생성자에서 스프링 컨테이너를 통해 주입받도록 하였다.
    public BookService(BookRepository bookRepository, UserLoanHistoryRepository userLoanHistoryRepository) {
        this.bookRepository = bookRepository;
        this.userLoanHistoryRepository = userLoanHistoryRepository;
    }
    
    // 저장 로직 생략
    @Transactional
    public void loanBook(BookLoanRequest request) {
        Book book = bookRepository.findByName(request.getBookName())
            .orElseThrow(IllegalArgumentException::new);
        
        // 추가된 로직, user_loan_history를 확인해 예외를 던져준다.
        if (userLoanHistoryRepository.existsByBookNameAndIsReturn(book.getName(), false)) {
            throw new IllegalArgumentException("진작 대출되어 있는 책입니다");
        }
    }
}

if 문이 실행되지 않으면 대출되지 않은 책이라는 뜻입니다. 따라서 대출 기록을 쌓아주면 됩니다. 이때 userId 가 필요하기 때문에 유저 객체를 가져온 후 UserLoanHistory 를 저장합니다. UserRepository 에 대한 의존성도 새로 필요하고, UserRepository 의 로직도 변경이 필요하며, UserLoanHistory 에 새로운 생성자도 필요합니다. 최종적인 Service 코드는 다음과 같습니다.

@Service
public class BookService {
    
    private final BookRepository bookRepository;
​
    private final UserLoanHistoryRepository userLoanHistoryRepository;
​
    private final UserRepository userRepository;
​
    public BookService(
        BookRepository bookRepository,
        UserLoanHistoryRepository userLoanHistoryRepository,
        UserRepository userRepository ) {
        this.bookRepository = bookRepository;
        this.userLoanHistoryRepository = userLoanHistoryRepository;
        this.userRepository = userRepository;
    }
​
    // 저장 로직 생략
    @Transactional
    public void loanBook(BookLoanRequest request) {
        Book book = bookRepository.findByName(request.getBookName())
            .orElseThrow(IllegalArgumentException::new);
        
        if (userLoanHistoryRepository.existsByBookNameAndIsReturn(book.getName(), false)) {
            throw new IllegalArgumentException("진작 대출되어 있는 책입니다");
        }
        
        User user = userRepository.findByName(request.getUserName())
            .orElseThrow(IllegalArgumentException::new);
        
        userLoanHistoryRepository.save(new UserLoanHistory(user.getId(), book.getName()));
    }
}

다음 시간에는 마지막 요구사항인 반납 기능을 개발합니다.


32강. 반납 기능 개발하기

요구사항

  • 사용자가 책을 반납할 수 있다.

API 스펙

  • HTTP Method : PUT

  • HTTP Path : /book/return

  • HTTP Body (JSON)

{
    "userName": String
    "bookName": String
}
  • 결과 반환 X (HTTP 상태 200 OK이면 충분합니다.)

BookReturnRequest DTO

public class BookReturnRequest {
    private String userName;
    private String bookName;
    
    public String getUserName() {
        return userName;
    }
​
    public String getBookName() {
        return bookName;
    }
}

컨트롤러 - returnBook 메서드 추가

@PutMapping("/book/return")
public void returnBook(@RequestBody BookReturnRequest request) {
    bookService.returnBook(request);
}

서비스 - returnBook 메서드 추가

@Transactional
public void returnBook(BookReturnRequest request) {
    User user = userRepository.findByName(request.getUserName())
        .orElseThrow(IllegalArgumentException::new);
    
    UserLoanHistory history = userLoanHistoryRepository.findByUserIdAndBookName(user.getId(), request.getBookName())
        .orElseThrow(IllegalArgumentException::new);
    
    history.doReturn();
}

UserUserLoanHistory 가 직접 협업할 수 있게 처리하도록 변경할 수 있지 않는지에 대해 다음 시간에 그 방법을 학습합니다.


2주차 미션

강의에서 학습한 범위 내에서 미션을 풀어내는 것을 목표로 진행했습니다. 학습 효과를 높이기 위해 어떠한 자료 혹은 검색 없이 스스로 문제 해결을 하려고 노력하고 한 줄마다 의미를 명확하게 이해하고 적용했습니다.

여섯 번째 과제! (진도표 6일차)

  1. Memory 방식을 제외하고 MySQL 로 동작하도록 구현한다.

  2. FruitMySqlRepositoryEx06 리포지토리에 우선 순위를 부여하기 위해 @Primary 를 작성한다.

  3. 서비스에서 예외 처리를 수행. 리포지토리 새로 추가된isSalesFruitNotExist 메서드로 데이터가 있는지 확인한다.

나머지 코드들은 분리한 형태로 코드 분리된 결과입니다. 다음 링크에서 과제 코드를 확인할 수 있습니다.

일곱 번째 과제! (진도표 7일차)

과제 #7 제출 스레드 에서 각 코드에 대해 자세히 살펴볼 수 있습니다. 아래 링크는 각 커밋 메세지와 함께 구현한 코드입니다.


댓글을 작성해보세요.