ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 5. 그레이들 멀티프로젝트 - 데이터베이스 셋업 및 활용(1) - Entity, CollectionTable, 데이터베이스값 불러오기
    스프링개발자/301 - 아키텍처 2020. 10. 5. 12:20

    [배경]

    도커로 데이터베이스를 구동한 후, 데이터 값을 불러와서 어플리케이션에서 사용해보자.

    숫자, 문자 값을 불러와서 활용 할 수 있다. 이외에도, 데이터 값에따라 runtime의 특정 bean의 타입이 달라지는 configurable bean또한 만들어보자.


    1. 도커 셋업 및 인텔리제이 연동

    이전 글을 참고하자.

    docker images
    docker ps
    docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password --name mysql_latest mysql
    docker ps
    
    다운받은 도커 이미지를 확인하고, 
    현재 구동중인 container가 있는지 확인한다.
    다운 받은 이미지중의 하나(IMAGE ID)를 선택해서 이미지를 실행시킨다.
    실행되었는지 확인한다.

     

    username은 root이다

     

    데이터그립 콘솔에서 다음의 DDL을 실행시켜서 데이터베이스, client 테이블 그리고 초기데이터를 만들자.

    CREATE DATABASE monorepo;
    USE monorepo;
    SHOW TABLES;
    CREATE TABLE client (id VARCHAR(20), name VARCHAR(255) not null,
    guid VARCHAR(255) not null
           );
    DESCRIBE client;
    INSERT INTO client
           VALUES (1, 'ford','cac6cea0-8620-4d0f-be62-dd24168a40c2');
    INSERT INTO client
           VALUES (2, 'gmc','3f99ea1c-6c62-46ca-8e25-6f8ac34bb222');
    INSERT INTO client
           VALUES (3, 'fca','ff6fb705-b37a-415f-bcd5-7088936938e5');

     

    추가로 mono_parameter라는 테이블을 하나 더 만든다.

    CREATE TABLE mono_parameter (id int primary key, name VARCHAR(255) not null,
    value VARCHAR(255) not null,
           client_id VARCHAR(255) references client,
           constraint anyConstraint unique(client_id));
    DESCRIBE mono_parameter;
    INSERT INTO mono_parameter
           VALUES (1, 'vehicle','car','cac6cea0-8620-4d0f-be62-dd24168a40c2');
    INSERT INTO mono_parameter
           VALUES (2, 'vehicle','truck','3f99ea1c-6c62-46ca-8e25-6f8ac34bb222');
    INSERT INTO mono_parameter
           VALUES (3, 'vehicle','bicycle','ff6fb705-b37a-415f-bcd5-7088936938e5');

    2. 의존성 설정

    spring.jpa.hibernate.ddl-auto=update
    spring.datasource.url=jdbc:mysql://localhost:3306/monorepo
    spring.datasource.username=root
    spring.datasource.password=password

    monorepo는 데이터베이스 이름이다(client table만들때 함께 만듬).

     

    아래의 의존성을 root폴더의 build.gradle에 추가

    runtimeOnly 'mysql:mysql-connector-java'

     

    3. 데이터베이스 값 불러오기

    Entity 클래스, Repository, Service, Controller 정의하기

    @Entity
    @Data
    public class Client {
    
        @Id @GeneratedValue(strategy = GenerationType.AUTO)
        private Integer id;
    
        @NotNull
        private String name;
    
        @NotNull
        private String guid;
    
    }
    package com.example.monorepo.model.client;
    
    import org.springframework.data.repository.CrudRepository;
    
    public interface ClientRepository extends CrudRepository<Client, Integer> {
    
    }
    
    package com.example.monorepo.model.client;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;
    
    import java.util.Optional;
    
    @Service
    public class ClientService {
    
        @Autowired
        ClientRepository clientRepository;
    
        public Optional<Client> getClientById(Integer id){
            return clientRepository.findById(id);
        }
        
        public Client saveClient(String name, String guid){
            Client client = new Client();
            client.setName(name);
            client.setGuid(guid);
            return clientRepository.save(client);
        }
    
    }
    
    // MainController.java
    @GetMapping("/database/client")
    public String findClientNameByGuid(@RequestParam("id") Integer id){
        return clientService.getClientById(id).toString();
    }
    @RestController
    @RequestMapping(value = "/main")
    public class MainController {
    
        @Autowired
        PlanService planService;
    
        @Autowired
        ClientService clientService;
    
    	// 중략 ...
        
        @GetMapping("/database/findClient")
        public String findClientNameByGuid(@RequestParam("id") Integer id){
            return clientService.getClientById(id).toString();
        }
    
        @PutMapping("/database/saveClient")
        public String putClientwithNameAndGuid(@RequestParam("name") String name, @RequestParam("guid") String guid){
            return clientService.saveClient(name, guid).toString();
        }
    
    }
    

     

     

    4. 중간 테스트

    MainApplication을 실행시키고, 다음의 주소로 들어간다

    [GET] localhost:8080/main/database/findClient?id=1
    [PUT] localhost:8080/main/database/saveClient?name=test&guid=123-abc-xyz-000

    결과값으로 이런식으로 나옴을 확인;

    Optional[Client(id=2, name=gmc, guid=3f99ea1c-6c62-46ca-8e25-6f8ac34bb222)]
    Client(id=5, name=test, guid=123-abc-xyz-000)

    Optional이 붙는 이유는, 우리의 ClientRepository가 extend한 CrudRepository의 정의를 따르기 때문.

     

    5. 콜렉션테이블(CollectionTable)

    CollectionTable이 뭘까?

    /**
     * Specifies the table that is used for the mapping of
     * collections of basic or embeddable types.  Applied
     * to the collection-valued field or property.
     * 
     
    기본, 혹은 임베디드 타입의 콜렉션을 맵핑하는데 사용되는 테이블 종류.
    콜렉션을 가진 필드나 속성에 적용된다.

    Client 라는 부모 엔티티 클래스 안에 아무 타입의 자식 테이블들을 엔티티 클래스 없이 만들어보자.

     

    이런식으로 여러 어노테이션들과 함께 사용한다. 

    일단 보기엔 복잡?하지만, 따로 엔티티 클래스를 만들 필요가 없이, 하나의 엔티티에 모두 보기 쉽게 정리할 수 있다.

    @ElementCollection(fetch = FetchType.EAGER) // specifies a collection of instances of a basic type or embeddedable class
    @MapKeyColumn(name = "name") // specifies key column
    @MapKeyEnumerated(EnumType.STRING) // Using enum key
    @CollectionTable(name = "mono_parameter", joinColumns = @JoinColumn(name = "client_id"))
    @Column(name ="value", nullable = false) // specifies an constraint about a column
    @Builder.Default
    private Map<MonoParameter, String> monoParameters = new EnumMap<>(MonoParameter.class);
    package com.example.monorepo;
    
    public enum MonoParameter {
    
        VEHICLE("car");
    
        private final String defaultValue;
    
        MonoParameter(String defaultValue) { this.defaultValue = defaultValue; }
    
        public String getDefaultValue(){ return defaultValue; }
    }

    6. 콜렉션테이블 테스트

    다음의 endpoint를 MainController에 추가한다.

    @PostMapping("/drive")
    public Double postEstimation(@RequestBody @Valid PUDO pudo, @RequestHeader("guid") String clientId){
        Client client = clientService.getClientById(clientId);
        Double estimate = planService.estimate(pudo, client);
        return estimate;
    }
    @Service
    public class ClientService {
    
        @Autowired
        ClientRepository clientRepository;
    
        @Transactional(isolation = Isolation.SERIALIZABLE)
        public synchronized Client getClientById(String guid){
            return clientRepository.getByGuid(guid).orElseThrow(() -> new ClientNotFoundException(guid));
        }
        
        // ... 중략
        // PlanService.java
        public Double estimate(PUDO pudo, Client clientId){
            Double x_distance = pudo.getX_finish() - pudo.getX_start();
            Double y_distance = pudo.getY_finish() - pudo.getY_start();
            Double total_distance = sqrt(pow(x_distance,2) - pow(y_distance,2));
    
            double vehicleSpeed = getVehicleSpeed(clientId);
            return total_distance/vehicleSpeed;
        }
    
        private double getVehicleSpeed(Client client) {
            String vehicleType = client.getMonoParameters().getOrDefault(
                    MonoParameter.VEHICLE,
                    MonoParameter.VEHICLE.getDefaultValue()
            );
    
            double speed = 0;
    
            switch(vehicleType){
                case "car":
                    speed = 50;
                    break;
                case "truck":
                    speed = 30;
                    break;
                case "bicycle":
                    speed = 10;
                    break;
                default:
                    speed = 5;
            }
    
            return speed;
        }
    }

    postman 테스트

    json body below:

    {
        "x_start" : 1,
        "y_start" : 2,
        "x_finish" : 11,
        "y_finish" : 9
    }

    POST로 구현했다. GET과의 차이점은 이전글을 보면 정리되어 있다. 여러 정보를 건내줘야 할 경우엔, 좀 더 깔끔하고 안전한 콜을 위해서 POST가 좋다.

     

    client에 따른 estimate endpoint 실행 결과

     

    핵심코드는 아래와 같다.

    String vehicleType = client.getMonoParameters().getOrDefault(
                    MonoParameter.VEHICLE,
                    MonoParameter.VEHICLE.getDefaultValue()
            );

    Refactoring

    // Optional이 거슬린다
    
    @Autowired
    ClientRepository clientRepository;
    
    public Optional<Client> getClientById(String id){
        return clientRepository.findById(id);
    }
    // 예외처리를 해서 Optional을 없애자
    
    @Autowired
    ClientRepository clientRepository;
    
    public Client getClientById(String id){
        return clientRepository.findById(id).orElseThrow(() -> new ClientNotFoundException(id));
    }

    TroubleShooting

    1. Foreignkey error

    Error executing DDL "alter table mono_parameter drop foreign key 
    FKe1f9pkapuix9fhvgkgdirebpr" via JDBC Statement

    테이블을 혼동해서 발생하는 메세지일 수 있다. 두개의 테이블이 있는데, 테이블 이름을 명시하지 않았다.

    Client에 @Table 어노테이션과 이름을 추가해준다. @Table(name = "client")

    문제가 사라졌다.

     

     

    2. test error message: No tests found for given includes:

    -> Gradle Test using Intellij

     

    3. 실수로 entity class에 @id를 두개 넣으면 발생하는 예:

    만약 정말 @Id가 두개 필요한 경우엔, Serializable 을 넣어주고, IdClass를 정의해야 된다.

    org.springframework.beans.factory.BeanCreationException: 
    Error creating bean with name 'clientRepository' defined in 
    com.example.monorepo.model.client.ClientRepository defined in 
    @EnableJpaRepositories declared on JpaRepositoriesRegistrar.EnableJpaRepositoriesConfiguration:
    Cannot resolve reference to bean 'jpaMappingContext' while setting bean property 
    'mappingContext'; nested exception is org.springframework.beans.factory.BeanCreationException:
    Error creating bean with name 'jpaMappingContext': Invocation of init method failed; 
    nested exception is javax.persistence.PersistenceException: [PersistenceUnit: default] 
    Unable to build Hibernate SessionFactory; nested exception is org.hibernate.MappingException: 
    Composite-id class must implement Serializable: com.example.monorepo.model.client.Client
    	
    org.springframework.beans.factory.BeanCreationException: 
    Error creating bean with name 'clientRepository': 
    FactoryBean threw exception on object creation; 
    nested exception is java.lang.IllegalArgumentException: 
    This class [class com.example.monorepo.model.client.Client] does not define an IdClass

    나의 경우에는 실수였으므로, @Id어노테이션을 하나만 남기고 지웠다.

     

    4. 데이터베이스 비밀번호가 틀린경우

    org.springframework.beans.factory.BeanCreationException: 
    Error creating bean with name 'clientRepository' defined in 
    com.example.monorepo.model.client.ClientRepository defined in 
    @EnableJpaRepositories declared on JpaRepositoriesRegistrar.EnableJpaRepositoriesConfiguration:
    Cannot resolve reference to bean 'jpaMappingContext' while setting bean property 
    'mappingContext'; nested exception is org.springframework.beans.factory.BeanCreationException: 
    Error creating bean with name 'jpaMappingContext': Invocation of init method failed; 
    nested exception is org.hibernate.service.spi.ServiceException: 
    Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment]
    

    비밀번호가 틀리다고 정확하게 나오면 조으련만 이런식으로 나온다.

     

    5. CrudRepository vs JpaRepository

    Crud Repository is the base interface and it acts as a marker interface. 
    JPA also provides some extra methods related to JPA such as delete records
    in batch and flushing data directly to a database. 
    It provides only CRUD functions like findOne, saves, etc. 
    JPA repository also extends the PagingAndSorting repository
    
    CrudRepository는 가장 기본기능에 충실한 interface이고,
    JpaRepository가 좀 더 기능이 추가된 형태(Paging and Sorting)

     

    6. @RequestParam vs @RequestHeader

    Param은 URL에 정보를 첨부하는 GET에서 사용되는 방식

    Header는 POST에서 사용되는 좀 더 안전한 방식

     

    7. SQLException

    Caused by: java.sql.SQLException: Failed to add the foreign key constraint. 
    Missing index for constraint 'FKe1f9pkapuix9fhvgkgdirebpr'
    in the referenced table 'client'

    Client테이블을 만들때 primary key를 빠뜨렸다. 부모 테이블의 primary key를 설정하지 않아서, 자식 테이블을 호출할때 foreign key에러가 났다.

    DROP TABLE client;
    CREATE TABLE client (name VARCHAR(255) not null,
    guid VARCHAR(255) not null primary key
           );
    DESCRIBE client;
    INSERT INTO client
           VALUES ('ford','cac6cea0-8620-4d0f-be62-dd24168a40c2');
    INSERT INTO client
           VALUES ('gmc','3f99ea1c-6c62-46ca-8e25-6f8ac34bb222');
    INSERT INTO client
           VALUES ('fca','ff6fb705-b37a-415f-bcd5-7088936938e5');

    8. IllegalArgumentException: No enum constant

    디버깅에 가장 시간을 많이 쏟은 예외이다.

    java.lang.IllegalArgumentException: 
    No enum constant com.example.monorepo.MonoParameter.vehicle

    MonoParameter에 vehicle이라는 상수가 없다는데, 분명히 있다.

    public enum MonoParameter {
    
        VEHICLE("car");

    하지만, 소문자 vehicle인걸로 보아서, 대문자 vehicle이 우리의 키인데, 어디에서인가 소문자로 인식하는 케이스 문제?

    Client클래스에 @MapKeyColumn(name ="name") 으로 입력했으니, 이 테이블의 key가 name인데, 아마 vehicle로 소문자로 되어 있어서 인거 같다. VEHICLE대문자로 바꿔보자. 제발 되길..

    아.. 된다.. 감사 ㅠㅠ

    역시 구글에 물어보는 것도 도움이 되지만(똑같거나 비슷한 상황일때만 도움된다),

    IDE의 error trace메세지가 가장 큰 도움이 되는거 같다.

    롤 URF열렸으니 르블랑이나 한판하러.

     

    최종 코드는 여기에

    github.com/2ndPrince/monorepo/tree/database-1

     

    이전 branch와 compare결과

    github.com/2ndPrince/monorepo/compare/eureka...database-1

     

    다음 시간에는 데이터베이스 값에 따른 자동 bean 설정을 해보자.

    Car, Truck 그리고 Bicycle 을 component로 만들고,

    POST request에 입력하는 client guid값에 따라서 다른 bean이 자동설정되도록.

Designed by Tistory.