[LG CNS 6기] 본 과정 17일차 TIL / [백엔드] - Stream API, Optional, Spring Boot 아키텍처

김승진·2026년 8월 20일

LG CNS AM 6기 TIL

목록 보기
26/46

1. 오늘의 한 줄 요약

Stream API 메서드 실습 및 Optional로 null 처리 정리, 콘솔 블로그에 상세보기, 검색, 입력까지 연결해서 Spring Boot 아키텍처 흐름을 확장했다.

2. 배운 내용

2.1 Spring Boot 아키텍처와 MVC

Spring 아키텍처 설계에서 요청을 제일 먼저 받는 게 DispatcherServlet이다. MVC는 Model-View-Controller를 뜻한다.

Client → DispatcherServlet → Controller → Service → Repository → DB

어제 직접 만든 구조(View → FrontController → BeanFactory → Controller → Service → Dao)가 바로 이 흐름을 자바 문법만으로 미리 만들어본 것이다. BlogFrontController가 DispatcherServlet 역할, BlogBeanFactory의 Map이 HandlerMapping 역할에 대응된다.

2.2 Session Facade Pattern (Front Controller) 복습

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는 몰라도 상관이 없다.

2.3 BeanFactory - Controller 인스턴스 관리

어제는 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는 딱 하나씩만 만들어지고, 각 컨트롤러가 그 하나를 공유해서 받는다(생성자 주입).

2.4 Post CRUD Controller 작성

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로 바인딩해서 넘긴다는 것만 다르다.

2.5 Stream API 메서드 실습

글 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 얘기도 잠깐 하셨는데

  • flatMap은 중첩된 구조를 평탄화한다.
  • peek은 디버깅용으로 중간에 값 확인할 때 쓰는 건데, 원래 디버깅 용도로만 설계된 거라 최적화 과정에서 일부 요소는 peek 코드가 아예 안 불릴 수도 있어서 권장하지는 않는다고 한다.

2.6 Optional로 NPE 회피하기

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는 없으면 그냥 예외를 던져버린다.

2.7 단건조회 vs 검색

// 단건 조회(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();
}

둘 다 똑같이 못 찾았을 때 상황인데 처리가 다르다.

  • 단건 조회는 결과가 하나여야 하니까 Optional로 감싸고, Service에서 orElseThrow()로 못 찾으면 예외를 던지게 했다.
  • 검색은 결과가 0개인 것도 정상적인 결과라서(글이 없는 경우), 그냥 빈 리스트를 그대로 반환하면 된다.

2.8 오늘 실제로 연결한 것 - 상세보기 / 검색 / 입력

// 상세보기: 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까지 늘어났다.

3. 실습 / 적용

직접 해본 것:

  • BlogStreamApp에서 filter/map/collect/groupingBy/평균/distinct/sorted/match 계열 메서드
  • Optional.of()/ifPresentOrElse()/Optional.empty()/orElseThrow()
  • 메뉴 2(상세보기), 6(검색), 3(입력)을 View부터 Dao까지 연결

결과:

  • Stream/Optional 예제와 상세보기, 검색으로 실제로 데이터가 왕복되는 것까지 확인했다.

4. 오늘의 회고

  • 느낀 점: 오늘따라 컨디션이 안 좋아서 집중하기가 힘들었다. 흐름을 많이 놓쳤지만, 무한 복습으로 다시 따라가야겠다.
  • 다음에 할 것: 어제, 오늘 코드 복습!!!

#LGCNS #LGCNS6기 #개발자 #LGCNSINSPIRECAMP

profile
이것저것

0개의 댓글