기본 콘텐츠로 건너뛰기

냉장고 가계부 프로젝트 37

fridge 프로젝트도 member 프로젝트와 같이 Spring Data Rest 를 사용하여 코드를 정리한다.
response 본문에 id 가 표시되도록 RepositoryRestConfig 클래스를 작성한다.
@Configuration
public class RepositoryRestConfig extends RepositoryRestConfigurerAdapter {

    @Override
    public void configureRepositoryRestConfiguration(RepositoryRestConfiguration config) {
        config.exposeIdsFor(Fridge.class, Food.class);
    }

}

Fridge, Food 클래스 두 엔티티의 Id를 노출한다.

FridgeRepository, FoodRepository 인터페이스에 @RepositoryRestResource 애너테이션을 선언한다.
@RepositoryRestResource
public interface FridgeRepository extends JpaRepository<Fridge, Integer> {
    Optional<Fridge> findByUserId(@Param("userId") Long userId);
}
@RepositoryRestResource
public interface FoodRepository extends JpaRepository<Food, Long> {
    
}

FridgeRepository의 findByUserId 메서드의 파라미터에 @Param("userId") 애너테이션을 선언한다.
findByUserId 메서드는 fridges/search/findByUserId GET URL 형태로 제공된다. @Param("userId") 를 선언하면 QueryString userId를 키로 사용한다.
예를들어 http://localhost:8080/fridges/search/findByUserId?userId=1004 처럼 호출하면 쿼리스트링으로 전달된 파라미터 UserID 값을 가진 Fridge 객체가 응답결과로 전달된다.

이제 fridge 프로젝트의 controller, service 등의 클래스가 전부 정리된다.

fridge 프로젝트의 API endpoint 가 변경됨에 따라 web 프로젝트도 코드를 수정한다.

FridgeClient 클래스는 Feign Client로 fridge 서버와 HTTP 통신을 한다. 해당 클래스는 다음과 같이 수정한다.
@FeignClient(name="fridge-service", configuration=FeignConfig.class)
public interface FridgeClient {
    
    @GetMapping("/fridges/search/findByUserId")
    Fridge loadByUserId(@RequestParam("userId") long userId) throws NotFoundException;
    
    @GetMapping("/fridges/{id}/foods")
    Resources<Food> loadFoodsByFridgeId(@PathVariable("id") int id);
    
    ...

}

다른 메서드는 동일하지만 loadByUserId 메서드는 /fridges/search/findByUserId URL을 사용해서 Fridge 객체를 응답결과로 리턴받는다. 해당 URL은 쿼리스트링으로 userId 를 사용하므로 @RequestParam 애너테이션을 메서드 파라미터에 전달해서 userId 값을 쿼리스트링으로 호출한다.

loadFoodsByFridgeId 메서드는 새롭게 생성된 메서드인데, 기존의 API는 loadByUserId 를 호출해서 Fridge 객체를 리턴받으면 Fridge가 식품목록을 가지는 경우 배열로 응답결과에 식품목록이 같이 전달되었지만, Spring Data Rest를 사용함으로써 foods목록은 /fridges/{id}/foods GET URL에 매핑되어있다.
loadFoodsByFridgeId 메서드는 /fridges/{id}/foods GET URL을 호출하며, Resources<Food> 타입을 리턴한다. @PathVariable("id") int id값은 Fridge 식별자이다.

응답본문의 JSON 값은 다음과 같이 전달된다.
{
  "_embedded": {
    "foods": [
       {
          "id": 1,
          ...
       }
    ],
    "_links": {
         ...
    }
  }
}

"_embedded" 와 같은 JSON 값이 존재하기 때문에 List<Food> 타입으로 리턴받을 수 없다. 그래서, HATEOAS의 Resources 클래스로 리턴받는다. 이를 위해서 별도의 Feign Decoder 설정을 해줘야한다.
Feign 설정을 위해 FeignConfig 클래스를 작성한다.
@Configuration
public class FeignConfig {
    
    @Bean
    public Decoder feignDecoder() {
        HttpMessageConverter jacksonConverter = new MappingJackson2HttpMessageConverter(customObjectMapper());
        ObjectFactory<HttpMessageConverters> objectFactory = () -> new HttpMessageConverters(jacksonConverter);
        return new ResponseEntityDecoder(new SpringDecoder(objectFactory));
    }

    public ObjectMapper customObjectMapper(){
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        JavaTimeModule javaTimeModule = new JavaTimeModule();
        javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer());
        javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer());
        objectMapper.registerModules(new Jackson2HalModule(), javaTimeModule);
        return objectMapper;
    }
    
    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    
    private static class LocalDateSerializer extends JsonSerializer<LocalDate> {
        @Override
        public void serialize(LocalDate value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
            gen.writeString(value.format(FORMATTER));
        }
    }
    
    private static class LocalDateDeserializer extends JsonDeserializer<LocalDate> {
        @Override
        public LocalDate deserialize(JsonParser p, DeserializationContext ctxt)
                throws IOException, JsonProcessingException {
            return LocalDate.parse(p.getValueAsString(), FORMATTER);
        }
    }
    
}

feignDecoder Bean 메서드는 feign.codec.Decoder 를 리턴하는데 디폴트 설정을 덮어쓴다. MessageConverter를 설정하는데 customObjectMapper 를 전달한다. customObjectMapper 메서드는 ObjectMapper 클래스를 생성해서 리턴한다. DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES 값을 false로 설정한다.
말 그대로 역직렬화시 알수없는 속성값이 있으면 역직렬화에 실패하는 설정을 끈다.

그리고, JavaTimeModule 클래스는 JSR-310 스펙으로 java.time 패키지의 JSON 직렬화 관련 모듈이다.
JavaTimeModule 에 LocalDate의 JSON 직렬화, 역직렬화 클래스를 추가한다.
직렬화 클래스는 LocalDateSerializer, 역직렬화 클래스는 LocalDateDeserializer 이다. 내부에는 DateTimeFormatter의 "yyyy-MM-dd" 포맷형식으로 직렬화한다고 선언한다.

ObjectMapper에 모듈을 등록하는데 (registerModules), JavaTimeModule 과 Jackson2HalModule 을 등록한다. Jackson2HalModule 은 HATEOAS 의 형태(link, rel)에 맞춰서 직렬화해주는 모듈이다.

Application 클래스에 @EnableHypermediaSupport 애너테이션을 추가한다.
@EnableHypermediaSupport(type=HypermediaType.HAL)
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class Application {
    
    @Bean
    public LayoutDialect layoutDialect() {
        return new LayoutDialect();
    }
    
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
    
}

FridgeClient 의 loadFoodsByFridgeId 메서드의 리턴타입이 Resources<Food> 이기 때문에, 기존에 Fridge 클래스의 List<Food> foods 멤버 변수의 리턴타입을 Collection<Food> 로 수정한다.
@NoArgsConstructor
@Data
public class Fridge {
    private Integer id;
    @NotNull
    @Size(min=2, max=15)
    private String nickname;
    private Collection<Food> foods;
    @NotNull
    private Long userId;
    
    @Builder
    public Fridge(Integer id, String nickname, Long userId) {
        this.id = id;
        this.nickname = nickname;
        this.userId = userId;
    }

}

FridgeController 클래스에서 foods 목록을 View에 전달하기 위해서 수정한다.
@Controller
@RequestMapping("/fridges")
@RequiredArgsConstructor
@Slf4j
public class FridgeController {
    private final FridgeClient client;
    
    private static final String DEFAULT_NICKNAME = "myFridge";
    
    @GetMapping("/me")
    public String myFridge(@AuthenticationPrincipal Member member, Model model) {
        Fridge fridge = null;
        try {
            fridge = client.loadByUserId(member.getId());
        } catch(NotFoundException ex) {
            log.info(ex.getMessage());
            fridge = client.generate(Fridge.builder()
                    .nickname(DEFAULT_NICKNAME)
                    .userId(member.getId()).build());
        }
        
        Resources<Food> resources = client.loadFoodsByFridgeId(fridge.getId());
        fridge.setFoods(resources.getContent());
        model.addAttribute("fridge", fridge);
        return "fridges/fridge";
    }
    
}

client.loadFoodsByFridgeId 메서드를 호출해서 리턴값을 resources 변수에 담고, fridge 변수에 setFoods로 전달한다. Resources 클래스로 감싸고 있는 Food 목록값은 getContent메서드로 리턴된다.

이전글: 냉장고 가계부 프로젝트 36
다음글: 냉장고 가계부 프로젝트 38

이 블로그의 인기 게시물

Dolphin 3

100자 이내의 짧은 글을 쓸 수 있는 게시판 제공. Dolphin Project에서 가장 메인 서비스는 예적금 시뮬레이터이므로, 다른 기능 구현은 차후로 미뤄뒀지만, 사용자와 소통할 수 있는 최소한의 기능은 제공하는게 맞다는 생각이 들어 아주 단순한 게시판 기능을 제공합니다. 사용자가 글을 쓰려면 로그인을 먼저 해야합니다. 글을 등록 후 삭제할 때 글 작성자를 식별할 수 있어야 하기 때문입니다. 게시판은 Dolphin Frontend Main 페이지에 자리잡았습니다. 사용자가 가장 먼저 만나게 되는 웹페이지이므로 적당하다고 생각합니다. 다음은 로그인을 하기 전 사용자가 볼 수 있는 메인 페이지입니다. 글 상단에는 작성일(MMM-dd-yyyy)과 작성자 고유번호(#numbers)가 노출됩니다. Dolphin 은 네이버 로그인을 사용하기 때문에, 사용자를 식별할 수 있는 정보는 저 고유번호가 됩니다. 하단에는 글 내용이 자리잡습니다. 스크린샷에서는 Hello, world!! 라는 문구가 보이네요. 글은 최대 100자까지 허용합니다. 버튼 두개(Newer, Older) 는 페이징 버튼인데, Newer는 최근페이지로 이동을 하며, Older는 지나간 글을 보는 페이징버튼입니다. 기본 페이지 사이즈는 20개입니다. 오른쪽으로 보이는 글 작성 폼은 현재 로그인 하기 전이므로 textarea 는 disabled 처리되어 있고 Login버튼이 자리잡고 있습니다. 다음은 사용자가 로그인 한 뒤의 메인페이지입니다. 변경된 부분은 사용자 고유번호 뒤에 Remove 버튼이 나타났습니다. 해당 글 작성자가 로그인한 사용자일 경우에만 보이게 됩니다. 오른쪽 글 작성 폼도 이제 활성화 되었습니다. 아주 단순한 Textarea 창과 Submit 버튼이 자리잡고 있습니다. 다음은 작성자가 아닌 다른 사용자가 로그인한 경우 입니다. 사용자 고유번호(#54097272) 옆에 Remove 버튼이...

Dolphin 2

별도의 회원가입 없이 소셜 로그인 기능 제공으로 간편한 로그인 가능. Dolphin 프론트엔드는 회원가입 절차가 따로 없고, 간단하게 소셜 로그인 기능을 제공함으로써 회원가입, 인증절차를 생략했습니다. 우선, 간단하게 국내에서 잘 알려진 네이버의 API를 사용합니다. 네이버 개발자센터 웹사이트 에서 네이버 아이디로 로그인 기능을 살펴봅니다. 개발 문서를 자세히 살펴보고 API 신청을 합니다. 애플리케이션의 이름을 정하고 필요한 정보를 필수/선택에 체크합니다. 밑으로 내려보면 로그인 API 서비스 환경 부분에서 환경 추가 셀렉트박스에서 PC 웹을 선택하면 다음과 같은 입력폼이 나타납니다. 서비스 URL은 내 웹애플리케이션의 URL이 될 예정입니다. 지금은 개발상태이므로 localhost를 입력했습니다. Callback URL은 네이버 OAuth 인증 url로 호출하면, 인증절차 후 네이버에서 리다이렉트해줄 url을 뜻합니다. 저는 /naver/login 으로 했습니다. 등록이 완료되면 client-id와 client-secret 값을 제공해줍니다. 이 값을 이용해서 인증처리를 할 수 있습니다. spring.io 웹사이트의 튜토리얼 문서를 보면 Facebook 소셜 로그인 기능에 대한 설명이 자세하게 나와있습니다. 이 부분을 참고합니다. Spring Boot and OAuth2 pom.xml 파일에 의존성을 추가하고 Application 클래스에 @EnableOAuth2Sso 애너테이션을 선언합니다. 튜토리얼대로 application.yaml 파일(application.properties)에 설정 정보를 입력합니다. clientId, clientSecret 값은 네이버 개발자센터에서 받은 대로 입력합니다. accessTokenUri는 네이버의 경우 https://nid.naver.com/oauth2.0/token 입니다. userAuthorizationUri는 네이버의 경우 https://ni...

냉장고 가계부 프로젝트 6

JdbcTemplate 을 사용하여 DB에 객체 데이터를 저장하고 관리하는 것보다 Java Persistence API (JPA) 기술 표준을 사용하면 생산성이 훨씬 향상된다. JPA 를 구현하는 프레임워크들 중에서는 Hibernate가 가장 많이 사용되고 있다. 이제는 JPA와 Hibernate가 같은 의미로 불리기도 한다. JdbcTemplate이나, MyBatis 를 이용하면 SQL Query문을 벤더에 맞게 직접 작성하게 되고, CRUD 같은 쿼리의 경우 단순하게 반복/중복적으로 표현된다. ex)MyBatis <select id="findByUsername" parameterType="String" resultType="User"> SELECT * FROM users WHERE username = #{username} </select> ex)JPA public interface UserRepository extends JpaRepository<User, String> { User findByUsername(String username); } 위의 MyBatis는  보통 UserDao.xml 의 쿼리문을 저장하는 XML 파일이 있고, UserDao.java 인터페이스가 따로 존재한다. 이렇듯 JPA는 생산성이 좋고, MyBatis는 쿼리문을 직접 작성하니까 해당 벤더에 특화된 쿼리문을 작성해서 성능에 이점이 있다. 이 두가지 장점을 다 가져가려면 단순한 쿼리는 JPA를 이용하고, 통계나 리포트같은 부분은 MyBatis를 이용하면 된다. JPA를 사용하기 위해서는 pom.xml 에 spring-boot-starter 를 data-jpa로 수정한다. <dependency> <groupId>org.springframework.boot</groupId> <artifactI...