반응형

7일동안 과제를 마치고 이제 미니프로젝트 과제를 하게되었다. api 명세를 받아서 

api를 개발하면 되는데 지금 다 끝난시점에서 쓰는거지만 하나씩 할때마다 테이블에대해서도 고민하고 쿼리에대해서도 여러가지 생각을 해보게되는 좋은 경험이었다. 

 

[ 프로젝트 1단계 ]

SpringInitilizer로 스프링프로젝트를 생성한후에 실행해주었다.

 

웹으로 접근이 가능하게 하기위해서 spring web

Jpa를 쓰기위해서 spring Jpa

MySql 데이터베이스를 사용하기위해서 MySQL Driver

코드를 줄이고 편하게작성하기위해서 lombok을 의존성에 추가해서 프로젝트를 만들어주었다.

Dependencies추가

 

 

이제 필요한 기능명세에 따라서 기능을 만들어주겠다.

 

위의 기능을 보고 컨트롤러를 작성하기전에 DB를 먼저 생성해주었다. 

필요한 정보를보면 직원의정보 , 직원이 속한 부서 ,직원의 직급이 필요하다.

 

직원의 정보에 부서와,직급정보가 모두 포함될수도있지만 중복이 발생하기때문에 서로 분리해주었다.(정규화)

 

[직원]

직원코드, 직원 이름 , 직급(코드) , 부서(코드) , 생일 , 입사일

 

[직급]

직급코드, 직급이름

 

[부서]

부서코드, 부서이름 , 부서장(직원코드)

 

이렇게 간단하게 개념을 정리하고 테이블을 설계해주었다 코드는 기본키로설정하고

auto Increase로 자동증가하도록 해주었다. 

 

그리고 이런개념을 정리하기위해서 erd를 작성해보았다.

 

아래로가는 화살표는 이후 과제를 하면서 추가되는 테이블이다.

직급과 부서에는 해당하는 정보만 저장되도록하고 직원테이블에는 직급과 부서를 외래키로 참조하도록해서

직급의 이름과 부서의 이름을 받아올수있도록 해주었다. 

(사실 이게 제대로 정규화가 된건지는 잘모르겠다.)

이어진 선을보면 알수있는데 직급,부서 하나에 직원이 여러명 연결된 1:N관계이다.

 

이제 이 구조대로 자바코드에 DAO객체를 만들어주고 JpaRepository를 만들어주면 api를 만들 준비가 끝난다.

 

Employee(DAO)

@Entity
@Getter
@NoArgsConstructor
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @ManyToOne
    @JoinColumn(name = "position_id")
    private Position position;

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;

    private String startDate;
    private String birthDay;
}

 

@NoArgsConstructor어노테이션을 사용하면 파라미터가없는 기본생성자가 생성된다.( lombok )

EmployeeRepository(Repository)

public interface EmployeeRepository extends JpaRepository<Employee,Long> {
    Employee findByName(String name);
}

 

직원의 이름으로 값을 받아올수있는 추상메서드를 하나 작성해두었다.

 

Position(DAO)

@Entity
@NoArgsConstructor
@Getter
public class Position {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long positionId;
    private String positionName;
    @OneToMany(mappedBy = "position")
    private List<Employee> employeeList;
}

 

 

직급은 여러개의 직원정보를 가질수가있다.  (1:N)

예를 들어 직급이 대리 인데이터를 직원테이블에서조회한다고하면 김대리,이대리,박대리 가 나올수있는것이다.

그래서 직급하나가 여러개의 직원정보를 가져서 1:N관계가 되는것이다.

Jpa에서는 이런관계를 @OneToMany로풀어낼수있다.

이 기능을 이용하기위해서는 직급코드를 가지고있는

Employee클래스에서도 @ManyToOne으로 표기를 해주어야 서로 매핑이 되어서 이용이 가능하다. 

 

이 관계에는 주인이있는데 상대방을 알고있는쪽이 주인이라고 생각하면 편하다 

직원 클래스는 직급의코드를 가지고있어서 직급에대한 정보를 알고있지만 

직급 클래스는 직원에대해서 어떠한 정보도 가지고있지않다. 

 

그래서 직급클래스에는 mappedBy속성으로 관계의 주인을 명시해주고

직원 클래스에는 @JoinColumn을 사용해서 외래키인 position_id를 명시해주어야한다. - (쿼리에 들어갈 컬럼이름)

PositionRepository(Repository)

public interface PositionRepository extends JpaRepository<Position,Long> {
    Position findByPositionName(String positionName);
}

직급이름으로 조회할수있게 추상메서드를 작성해주었다.

 

Team(DAO)

@Entity
@Getter
@NoArgsConstructor
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long teamId;
    private String teamName;
    private Integer teamLeaderId;
    @OneToMany(mappedBy = "team")
    private List<Employee> employeeList;
}

 

TeamRepository(Repository)

public interface TeamRepository extends JpaRepository<Team,Long> {
    Team findByTeamName(String teamName);
}

부서이름으로 조회할수있게 추상메서드를 작성해주었다.

 

 

 

\main\resources\application.yml

spring:
  datasource:
    url: "jdbc:mysql://localhost:3308/company"
    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

리소스에 applocation으로 설정정보를 작성까지 해주었다.

 

이제 데이터베이스에 접근하기위한 준비가 모두 끝났다. 이제 API명세를 보고 필요한 기능을 하나씩 작성해보자.

반응형
반응형

 

FruitService

(기존코드)

@Service
public class FruitServiceImpl implements FruitService{

    private final FruitRepository repository;

    public FruitServiceImpl(@Qualifier("first")FruitRepository repository) {
        this.repository = repository;
    }

    @Override
    public void saveFruit(String name, String date, Integer price) {
        repository.saveFruit(name,date,price);
    }

    @Override
    public void updateFruit(long id) {
        if(repository.isFruitExist(id)){
            throw new IllegalArgumentException("수정할 자료가 없습니다.");
        }
        repository.updateFruit(id);
    }

    @Override
    public FruitShopAmoutResponse getFruitStat(String name) {
        return repository.getFruitStat(name);
    }
}

 

FruitRepository

(기존코드)

@Repository
public class FruitSQLRepository implements FruitRepository {

    private final JdbcTemplate template;

    public FruitSQLRepository(JdbcTemplate jdbcTemplate) {
        this.template = jdbcTemplate;
    }

    @Override
    public void saveFruit(String name, String date, Integer price) {
        String sql = "insert into fruitshop (name,warehousedate,price) values (?,?,?)";
        template.update(sql, name, date, price);
    }

    @Override
    public void updateFruit(long id) {
        String sql = "update fruitshop set sell = true where id = ?";
        template.update(sql, id);
    }

    @Override
    public boolean isFruitExist(long id) {
        String readSql = "select id from fruitshop where id = ?";
        return template.query(readSql, (rs, rowNum) -> 0, id).isEmpty();
    }

    @Override
    public FruitShopAmoutResponse getFruitStat(String name) {
        String sql = "SELECT sell, SUM(price) as totalAmount FROM fruitshop WHERE name = ? GROUP BY sell";
        AtomicLong salesAmount = new AtomicLong(0);
        AtomicLong notSalesAmount = new AtomicLong(0);

        template.query(sql, new Object[]{name}, (rs, rowNum) -> {
            boolean status = rs.getBoolean("sell");
            long totalAmount = rs.getLong("totalAmount");

            if (status) {
                salesAmount.addAndGet(totalAmount);
            } else {
                notSalesAmount.addAndGet(totalAmount);
            }
            return null;
        });
        return new FruitShopAmoutResponse(salesAmount.get(), notSalesAmount.get());
    }
}

 

 

이전에 만들었던 SQL쿼리를 이용한  Repository와 Service를

Jpa를 이용한 케이스로 수정.

Fruit (DAO) - 테이블과 매칭되는 DAO객체에  @Entity와 @ID @Column으로 매칭

@Entity
public class Fruit {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id = null;
    @Column(nullable = false, length = 20)
    private String name;
    private Integer price; //타입 필드이름 이 테이블과 일치하면 @Column은 생략가능.
    private String wareHouseDate; // 테이블의 필드이름 ware_house_date
    private boolean sell = false;

    private static long seq = 1;

    public Fruit(String name, Integer price, String wareHouseDate) {
        this.name = name;
        this.price = price;
        this.wareHouseDate = wareHouseDate;
    }

    public Fruit() {

    }


    public String getName() {
        return name;
    }

    public long getPrice() {
        return price;
    }

    public String getWareHouseDate() {
        return wareHouseDate;
    }
    public void setSell(){
        this.sell = true;
    }
}

 

객체의 이름은 테이블과 매칭되어야하고

객체의 필드이름을 카멜케이스로 쓰게되면 테이블의 필드는 스네이크케이스로작성되어있어야한다.

 

만약 객체의 이름이 테이블의 이름과 다르다면 @Entity(name = "테이블이름") 으로 사용하면된다.

 

Repository (Interface)

public interface FruitRepositoryJpa extends JpaRepository<Fruit, Long> {
    //아래는 추상메서드 기본제공되는 메서드이외에 필요시 추가해서 사용이 가능하다.
    //메서드의 이름의로 쿼리가 자동으로 작성되어 구현되고
    //쿼리가 복잡하여 메서드의이름으로 구현이안되는경우 Query어노테이션을 통해서 쿼리를 사용할수있다.
    @Query(value = "SELECT sell, SUM(price) as totalAmount FROM fruit WHERE name = :name GROUP BY sell", nativeQuery = true)
    List<Object> findSellAndTotalAmountByName(@Param("name") String name);
    Long countByName(String name);
    List<Fruit> findByPriceGreaterThan(Integer price);
    List<Fruit> findByPriceLessThan(Integer price);
}

 

Interface로 repository를 만들어주고 JpaRepository를 상속받아준다. 

Jpa의 기본기능은 따로 상속받아서 오버라이딩 하지않아도.

save나 findById findAll같은 기본조회나 업데이트가 사용가능하다.

 

Service

@Service
public class FruitServiceJpa implements FruitService {

    private final FruitRepositoryJpa repository;
	
    //Jpa를 상속받은 repository를 주입받는다.
    public FruitServiceJpa(FruitRepositoryJpa repository) {
        this.repository = repository;
    }

    @Override
    public void saveFruit(String name, String date, Integer price) {
        repository.save(new Fruit(name, price, date));
    }

    @Override
    public void updateFruit(long id) {
        Fruit fruit = repository.findById(id).orElseThrow(IllegalArgumentException::new);
        fruit.setSell();
        repository.save(fruit);
    }

    @Override
    public FruitShopAmoutResponse getFruitStat(String name) {
        return mapToAmount(repository.findSellAndTotalAmountByName(name));
    }

    public Map<String, Long> getFruitCount(String name) {
        long count = repository.countByName(name);
        Map<String, Long> resultMap = new HashMap<>();
        resultMap.put("count", count);
        return resultMap;
    }

    private FruitShopAmoutResponse mapToAmount(List<Object> result) {
        AtomicLong salesAmount = new AtomicLong(0);
        AtomicLong notSalesAmount = new AtomicLong(0);
        result.stream()
                .map(obj -> (Object[]) obj)
                .forEach(rs -> {
                    boolean sell = (boolean) rs[0];
                    long totalAmount = ((BigDecimal) rs[1]).longValue();
                    if (sell) {
                        salesAmount.addAndGet(totalAmount);
                    } else {
                        notSalesAmount.addAndGet(totalAmount);
                    }
                });
        return new FruitShopAmoutResponse(salesAmount.get(), notSalesAmount.get());
    }

    public List<FruitResponse> getListCondition(String condition, Integer price) {
        if (condition.equals("GTE")) {
            // select * from fruit where price > 16000
            return repository.findByPriceGreaterThan(price).stream()
                    .map(fruit -> new FruitResponse(fruit.getName(), Integer.valueOf((int) fruit.getPrice()),
                            fruit.getWareHouseDate()))
                    .collect(Collectors.toList());
        } else{
            return repository.findByPriceLessThan(price).stream()
                    .map(fruit -> new FruitResponse(fruit.getName(), Integer.valueOf((int) fruit.getPrice()), fruit.getWareHouseDate()))
                    .collect(Collectors.toList());
        }
    }
}

 

Service에서 사용하는 repository를 Jpa를 상속받는 repositoryJpa로 주입받아서 

repository를 통해 Jpa의 기능을 사용할수있다. 

해당 인터페이스의 구현체를 따로구현하지않아도 구현체가 자동으로 생성되어주입되고

JpaRepository를 상속받으면서 빈으로도 자동등록되기때문에 따로 처리를 해줄필요도 없다. (간편)

 

기본 save나 update문 들은 Jpa에 구현되어있는 기능으로 만들수있었는데

getFruitStat은 조건과 그룹바이가 달려있어서 기본 쿼리만으로는 사용할수없어서

인터페이스에 따로 추상메서드를 만들어주었다.

@Query(value = "SELECT sell, SUM(price) as totalAmount FROM fruit WHERE name = :name GROUP BY sell", nativeQuery = true)
List<Object> findSellAndTotalAmountByName(@Param("name") String name);

 

@Query어노테이션 을 통해서 기존에 사용하던 쿼리문을 사용할수있다. 

name으로 조회해서 sell필드로 그룹을 만들어서 그룹의 가격을 List<Object>로 리턴한다.

 

@Override
public FruitShopAmoutResponse getFruitStat(String name) {
    return mapToAmount(repository.findSellAndTotalAmountByName(name));
}

private FruitShopAmoutResponse mapToAmount(List<Object> result) {
        AtomicLong salesAmount = new AtomicLong(0);
        AtomicLong notSalesAmount = new AtomicLong(0);
        result.stream()
                .map(obj -> (Object[]) obj)
                .forEach(rs -> {
                    boolean sell = (boolean) rs[0];
                    long totalAmount = ((BigDecimal) rs[1]).longValue();
                    if (sell) {
                        salesAmount.addAndGet(totalAmount);
                    } else {
                        notSalesAmount.addAndGet(totalAmount);
                    }
                });
        return new FruitShopAmoutResponse(salesAmount.get(), notSalesAmount.get());
    }

 

해당 쿼리를 통해서 List<Object>로 리턴되는값을 매핑해서

FruitShowAmountResponse객체로 만들어서 리턴해준다.

 

새로운 API를 추가하고 Jpa를 통해서 기능을 구현.

 

 

 

API 스펙

해당 API의 진입지점을 먼저 Controller에 만들어주자.

 

Controller

@GetMapping("/count") // api/v1/fruit 은 RequestMappint에서 명시되어있다.
public Map<String,Long> getFruitCount(@RequestParam String name){
    return service.getFruitCount(name);
}

 

"count": long인 Json을 리턴해주기위해서 Map타입을 리턴해주도록 했다.

 

Repository

public Map<String, Long> getFruitCount(String name) {
    long count = repository.countByName(name);
    Map<String, Long> resultMap = new HashMap<>();
    resultMap.put("count", count);
    return resultMap;
}

 

파라미터로 들어오는 이름으로 조회해서 갯수를 세는 쿼리를 만들어야하는데

Jpa는 interface에 있는 메서드의 이름으로 해당 쿼리를 구현해서 자동으로 만들어준다, 

Long countByName(String name); //select count(*) as count from fruit where name = ?

 

이렇게 인터페이스에 countByName이라는 함수를 만드는것만으로 기능을 구현할수있다. 

메서드의 이름을통해서 쿼리를 만드는거라고 생각하면 될것같다.

여기도 규칙이있는데 이 규칙은 필요할때 찾아보면서 익숙해지면 편하게 사용할수있을것같다. 

 

http://localhost:8080/api/v1/fruit/count?name=사과

 

 

이 문제도 Jpa를 통해서 쿼리를 만들지않고 추상메서드를 만드는것으로 해결할수있었다.

여기서는 조건에 따라 다른 쿼리문을 실행시켜줘야한다.

SQL문이었다면 쿼리문을 두개만들어놓고 조건에 따라 다른 쿼리를 실행시켜주거나

쿼리문 중간의 조건을 바꿔치기 하는방법으로 사용할수있을것같다.

Jpa에서는 쿼리문을 만들지않아도되기때문에 간단하게 추상 메서드를 두개 만들어서 사용해주었다.

 

repositoryJpa

List<Fruit> findByPriceGreaterThan(Integer price);
List<Fruit> findByPriceLessThan(Integer price);

 

Service

if (condition.equals("GTE")) {
    // select * from fruit where price > 16000
    return repository.findByPriceGreaterThan(price).stream()
            .map(fruit -> new FruitResponse(fruit.getName(), Integer.valueOf((int) fruit.getPrice()),
                    fruit.getWareHouseDate()))
            .collect(Collectors.toList());
} else{
    return repository.findByPriceLessThan(price).stream()
            .map(fruit -> new FruitResponse(fruit.getName(), Integer.valueOf((int) fruit.getPrice()), fruit.getWareHouseDate()))
            .collect(Collectors.toList());
}

 

이렇게 추상메서드를 두개만들고

service에서 문자열로들어오는조건에 따라서 다른 메서드를 실행하게 처리해서 적용해주었다. 

 

Controller

@GetMapping("/list")
public List<FruitResponse> getListCondition(String condition, Integer price){
    return service.getListCondition(condition,price);
}

 

 

Jpa를 새로 적용하면서 중간에 에러도 많이 발생했는데 거의 대부분이

객체의 필드와 조회된 테이블이 매칭되지않아서 생기는 문제였다. 

이후에 익숙해지고 컨버터 개념도 배우면 좀 쉽게 해결이가능할것같지만 우선은 지금사용하는정도로도 

코드의 양을 눈에 띄게 줄일수있고 보는걸로 db에서 어떤기능을 하는지 쉽게 파악할수있게되었다.

반응형

+ Recent posts