Stream API 메서드 실습 및 Optional로 null 처리 정리, 콘솔 블로그에 상세보기, 검색, 입력까지 연결해서 Spring Boot 아키텍처 흐름을 확장했다.
Spring 아키텍처 설계에서 요청을 제일 먼저 받는 게 DispatcherServlet이다. MVC는 Model-View-Controller를 뜻한다.
Client → DispatcherServlet → Controller → Service → Repository → DB
어제 직접 만든 구조(View → FrontController → BeanFactory → Controller → Service → Dao)가 바로 이 흐름을 자바 문법만으로 미리 만들어본 것이다. BlogFrontController가 DispatcherServlet 역할, BlogBeanFactory의 Map이 HandlerMapping 역할에 대응된다.
Facade는 프랑스어로 "건물의 정면(대문)"이라는 뜻이라고 한다.
public class BlogFrontController {
private BlogBeanFactory factory;
public BlogFrontController(){
factory = BlogBeanFactory.getInstance();
}
public List<BlogResponseDTO> list(String endPoint) {
Object controller = factory.getBean(endPoint);
return ((ListController)controller).list();
}
}
모든 요청이 이 FrontController 하나를 거쳐서 들어간다.
실제로 어떤 컨트롤러가 처리하는지는 View는 몰라도 상관이 없다.
어제는 list.inspire 하나만 등록돼 있었는데, 오늘은 상세보기/검색/입력용 컨트롤러를 추가하였다.
private BlogBeanFactory() {
map = new HashMap<>();
dao = new BlogReactDao();
service = new BlogReactServiceImpl(dao);
map.put("list.inspire", new ListController(service));
map.put("read.inspire", new ReadController(service));
map.put("search.inspire", new SearchController(service));
map.put("insert.inspire", new InsertController(service));
}
메뉴가 하나 늘어날 때마다 컨트롤러 클래스 하나 만들고, 여기에 한 줄만 추가하면 된다. 싱글톤 팩토리라서 Dao/Service는 딱 하나씩만 만들어지고, 각 컨트롤러가 그 하나를 공유해서 받는다(생성자 주입).
public class ReadController {
private BlogReactService service;
public ReadController() {}
public ReadController(BlogReactService service) { this.service = service; }
public BlogResponseDTO read(int blogId) {
return service.read(blogId);
}
}
public class SearchController {
private BlogReactService service;
public SearchController() {}
public SearchController(BlogReactService service) { this.service = service; }
public List<BlogResponseDTO> search(String keyword) {
return service.search(BlogRequestDTO.builder().keyword(keyword).build());
}
}
public class InsertController {
private BlogReactService service;
public InsertController() {}
public InsertController(BlogReactService service) { this.service = service; }
public int insert(String title, String content, String email) {
return service.insert(BlogRequestDTO.builder()
.title(title).content(content).email(email).build());
}
}
셋 다 패턴이 똑같다. View에서 받은 값을 그대로 Service로 넘기기 전에, 필요하면 BlogRequestDTO로 바인딩해서 넘긴다는 것만 다르다.
글 5개(react 10, java 20, spring 30, docker 40, msa 50 - 숫자는 조회수)를 가지고 여러 Stream 메서드를 돌려봤다.
// filter: 조건에 맞는 것만 추출
blogs.stream()
.filter(blog -> blog.getViewCnt() >= 30)
.forEach(blog -> System.out.println(blog));
// map: 다른 값/타입으로 변환
blogs.stream()
.filter(blog -> blog.getViewCnt() >= 30)
.map(blog -> blog.getEmail())
.forEach(System.out::println);
// collect: 최종적으로 List로 모으기
List<BlogResponseDTO> result = blogs.stream()
.filter(blog -> blog.getEmail().equals("hong"))
.toList();
filter는 "이 데이터를 남길지 말지" 고르고, map은 "이 데이터에서 필요한 값만 꺼내서 다른 형태로 바꾸는" 역할이다. 둘 다 중간 연산이라 이어붙일 수 있고, collect/forEach 같은 최종 연산이 붙어야 실제로 실행된다.
// groupingBy: 작성자(email) 기준으로 그룹화
Map<String, List<BlogResponseDTO>> map = blogs.stream()
.collect(Collectors.groupingBy(BlogResponseDTO::getEmail));
// mapToInt + average: 조회수 평균
double avg = blogs.stream()
.mapToInt(BlogResponseDTO::getViewCnt)
.average()
.orElse(0);
// distinct: 중복 제거
blogs.stream().map(BlogResponseDTO::getEmail).distinct().forEach(System.out::println);
// sorted: 조회수 내림차순 정렬
blogs.stream()
.sorted(Comparator.comparing(BlogResponseDTO::getViewCnt).reversed())
.forEach(System.out::println);
// anyMatch / allMatch / noneMatch
boolean hasHong = blogs.stream().anyMatch(blog -> blog.getEmail().equals("hong"));
boolean allOver20 = blogs.stream().allMatch(blog -> blog.getViewCnt() >= 20);
boolean noneUnder10 = blogs.stream().noneMatch(blog -> blog.getViewCnt() < 10);
average()가 double을 바로 주지 않고 Optional(정확히는 OptionalDouble)을 준다. 데이터가 하나도 없으면 평균을 낼 수 없으니까, 그 상황을 대비해서 orElse(0)으로 기본값을 정해줘야 했다.
flatMap, peek 얘기도 잠깐 하셨는데
NPE(NullPointerException)는 자바에서 가장 흔한 에러 중 하나다.
Optional<String> optional = Optional.of("lgcns");
optional.ifPresentOrElse(
value -> System.out.println(value),
() -> System.out.println("값이 없습니다."));
optional = Optional.empty();
String err = optional.orElseThrow(() -> new RuntimeException("값이 없습니다."));
Optional은 메서드 반환값이 있을 수도 없을 수도 있다는 걸 타입으로 미리 알려주는 문법이다. null.메서드()를 실수로 불러서 NPE 나는 걸 막으려고 쓴다고 배웠다. ifPresentOrElse는 값 있을 때/없을 때 각각 다른 동작을 넣을 수 있고, orElseThrow는 없으면 그냥 예외를 던져버린다.
// 단건 조회(blogId로 하나 찾기) - 있을 수도 없을 수도 있어서 Optional
public Optional<BlogResponseDTO> findById(int blogId) {
return blogs.stream()
.filter(blog -> blog.getBlogId() == blogId)
.findAny();
}
// 검색(키워드로 여러 개 찾기) - 없으면 그냥 빈 리스트가 정상
public List<BlogResponseDTO> findByKeyword(BlogRequestDTO request) {
return blogs.stream()
.filter(blog -> blog.getTitle().contains(request.getKeyword())
|| blog.getContent().contains(request.getKeyword()))
.toList();
}
둘 다 똑같이 못 찾았을 때 상황인데 처리가 다르다.
orElseThrow()로 못 찾으면 예외를 던지게 했다.// 상세보기: View → Front → BeanFactory → ReadController → Service → Dao
public BlogResponseDTO read(int blogId) {
return service.read(blogId);
}
Service에서는 Dao가 넘겨준 Optional을 최종적으로 이렇게 처리했다.
public BlogResponseDTO read(int blogId) {
return dao.findById(blogId)
.orElseThrow(() -> new RuntimeException(blogId+" 정보를 찾을 수 없습니다."));
}
검색(6번 메뉴)과 입력(3번 메뉴)도 같은 방식으로 Factory에 새 endpoint를 등록해서 연결했다. list.inspire 하나였던 어제 구조가 오늘 read.inspire/search.inspire/insert.inspire까지 늘어났다.
직접 해본 것:
BlogStreamApp에서 filter/map/collect/groupingBy/평균/distinct/sorted/match 계열 메서드Optional.of()/ifPresentOrElse()/Optional.empty()/orElseThrow()결과:
#LGCNS #LGCNS6기 #개발자 #LGCNSINSPIRECAMP