728x90
반응형
SMALL

이번에는 MyBatis라는 기술을 이용해서 Database와 통신하는 법을 알아보자.

 

이 역시 나는 사용하지 않을것 같다. 왜냐하면 이 MyBatis를 사용하기엔 QuerydslSpring Data JPA가 너무 강력하기 때문이다.

그래도 공부를 한 이유는 왜 이 MyBatis가 게임체인저가 되지 못하고 JPA, Querydsl이 나왔을까?에 초점을 두었다.

 

SMALL

 

이전 포스팅인 JdbcTemplate은 다 좋은데 동적 쿼리를 만들어내기가 쉽지만은 않았다.

 

[Renewal] JdbcTemplate

Spring과 데이터베이스를 연동할 때 사용되는 기술 중 여전히 잘 사용되는 기술인 JdbcTemplate을 사용하는 법을 정리하고자 한다.우선, JdbcTemplate은 기존 JDBC 기술을 직접 사용할 때 겪는 문제들을 해

cwchoiit.tistory.com

MyBatis는 동적 쿼리를 훨씬 간단하게 작성할 수 있다. 그리고 한가지 좋은 점은 눈으로 SQL문을 보기가 좀 더 간결하고 직관적이다. 왜냐하면 xml 파일로 SQL문을 작성하기 때문이다.

 

 

라이브러리 다운로드

우선 MyBatis를 다운받자.

build.gradle

//MyBatis
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0'

 

JdbcTemplate과 한가지 다른 점은 MyBatis는 스프링 부트가 공식적으로 지원하는 라이브러리가 아니다. 그래서 현재 사용중인 스프링의 버전과 가장 적합한 버전을 찾아주지 않기 때문에 버전 명시가 필요하다.

 

이렇게 라이브러리를 다운받으면 다음과 같이 외부 라이브러리 리스트에 Mybatis가 노출된다. 

  • 근데 한가지 눈이 가는 부분이 있다. 빨간 표시해둔 `ibatis`? 이건 뭐지?
  • 예전 이름이 ibatis고 지금은 mybatis로 변경된 것이다.
  • 그 이름이 그대로 남아있을 뿐이고 그냥 mybatis라고 생각하면 된다.
  • 그리고 라이브러리 보면, spring-boot-starter라는 이름과 autoconfigure 라는 이름의 라이브러리도 들어와 있는데 이거 친숙하다. 스프링 부트가 라이브러리 만들어낼 때 이런 이름을 사용하는데 mybatis는 스프링 부트에서 공식적으로 지원하는 라이브러리는 아니다. 근데 이름이 왜 이럴까? mybatis에서 직접 만든 라이브러리다. 스프링 부트와 사용할 때 사용자들에게 편리하게 설정을 해주도록 mybatis에서 직접 만들어서 넣어준 라이브러리다. 

 

application.yaml

mybatis:
  type-aliases-package: hello.itemservice.domain
  configuration:
    map-underscore-to-camel-case: true
logging:
  level:
    hello.itemservice.repository.mybatis: trace
  • mybatis를 사용하려면 간단한 설정을 해줘야 한다. 이건 필수는 아니고 설정하면 편하다. 
  • 우선, type-aliases-packagemybatis를 사용할 때 SELECT절로 가져오는 데이터를 객체로 바인딩할 때 어디에 있는 객체를 나타내는지 등을 작성하는 속성이다. 진행하면서 한번 더 이 부분에 대해 설명하겠다.
  • configuration.map-underscore-to-camel-case는 데이터베이스에서는 관례가 (_)를 사용하다 보니, 객체와 매핑할 때 alias를 사용해야 하는 불편함이 있다. 객체는 item_name 이 아니라 itemName으로 표기하니까. 그래서 SELECT절에서도 이렇게 사용해야 했다.
`select item_name as itemName`
  • 그리고 객체로 매핑할 때 해당 필드를 찾아서 값을 넣어주는 방식으로 진행됐는데, 이렇게 configuration.map-underscore-to-camel-case 속성을 true로 설정해주면 자동으로 (_)camelCase로 변환해준다.

Mapper

package hello.itemservice.repository.mybatis;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.util.List;
import java.util.Optional;

@Mapper
public interface ItemMapper {

    void save(Item item);

    void update(@Param("id") Long id, @Param("updateParam") ItemUpdateDto updateDto);

    List<Item> findAll(ItemSearchCond itemSearchCond);

    Optional<Item> findById(Long id);
}
  • MyBatis @Mapper라는 애노테이션이 필요한데, 이를 붙여야만 Mybatis 스프링 연동 모듈(아까 위에서 봤던 autoconfigure 라이브러리)이 스프링이 시작할 때 이 @Mapper가 있는 곳을 다 찾아서 인터페이스의 구현체를 내가 만든 xml 파일을 바라보며 직접 만들어주기 때문이다. 
  • 그리고 어떤건 @Param이 있고 어떤건 없는데, 이는 두 개 이상인 경우 @Param을 붙여야한다.

 

ItemMapper.xml

이제 실제 위 인터페이스의 구현체를 만들어야 하는데 그건 자바 파일로 만드는게 아니고 xml 파일로 만들어야 한다.

그리고 이 xml 파일의 경로는 임의로 정할수 있는게 아니고 반드시 인터페이스와 같은 패키지 경로와 일치해야한다. (물론 변경할 순 있다)

그래서 위 ItemMapper 인터페이스의 패키지 경로인 hello.itemservice.repository.mybatissrc/main/resources 하위에 만들고 ItemMapper.xml 파일을 만들자.

 

src/main/resources/hello/itemservice/repository/mybatis/ItemMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "- //mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="hello.itemservice.repository.mybatis.ItemMapper">
    <insert id="save" useGeneratedKeys="true" keyProperty="id">
        insert into item (item_name, price, quantity)
        values (#{itemName}, #{price}, #{quantity})
    </insert>
    
    <update id="update">
        update item
        set item_name=#{updateParam.itemName},
            price=#{updateParam.price},
            quantity=#{updateParam.quantity}
        where id = #{id}
    </update>
    
    <select id="findById" resultType="Item">
        select id, item_name, price, quantity
        from item
        where id = #{id}
    </select>

    <select id="findAll" resultType="Item">
        select id, item_name, price, quantity
        from item
        <where>
            <if test="itemName != null and itemName != ''">
                and item_name like concat('%', #{itemName}, '%')
            </if>
            <if test="maxPrice != null">
                and price &lt;= #{maxPrice}
            </if>
        </where>
    </select>
</mapper>
  • 우선 직관적으로 어떤 태그가 SELECT문, UPDATE문, INSERT문인지 알 것 같다. 태그 이름이 그렇게 알려주니까.
  • 그리고 속성으로 id를 작성하는데 이 id에 작성하는 값은 실제 내가 작성한 ItemMapper 인터페이스의 메소드 명으로 설정하면 된다.

하나하나 뜯어보자.

<insert id="save" useGeneratedKeys="true" keyProperty="id">
    insert into item (item_name, price, quantity)
    values (#{itemName}, #{price}, #{quantity})
</insert>

 

  • save() 메소드와 매핑될 SQL문이다. useGeneratedKeys 속성은 데이터베이스가 키를 자동으로 생성해주는 IDENTITY 전략일 때 사용한다. 이 값을 true로 했으면 keyProperty를 설정해야 한다. keyProperty는 기본키의 컬럼명이다.
  • 이제 파라미터를 넘겨받을 땐 `#{}` 문법을 사용한다. 이 안에 던질 데이터를 넣어주면 된다. save(Item item) 메소드의 이 item이 가지는 필드명이다. save(Item item) 메소드는 파라미터가 하나이기 때문에 바로 그 안에 필드에 접근할 수 있다.
    그래서 #{item.itemName}이 아니고 #{itemName}으로 작성하면 된다. 

 

<update id="update">
    update item
    set item_name=#{updateParam.itemName},
        price=#{updateParam.price},
        quantity=#{updateParam.quantity}
    where id = #{id}
</update>

 

  • 진짜 보기 편하게 생겼다. SQL문이 바로바로 보인다. 자 이제 update()를 보면 간단하다. 그냥 UPDATE 쿼리문을 작성하면 된다. 그리고 이 update() 메소드는 파라미터가 두개였기 때문에 @Param() 애노테이션에 괄호안에 작성한 값으로 파라미터를 바인딩해야 한다.
  • @Param("id")id@Param("updateParam")updateParam.xxx로. 

 

<select id="findById" resultType="Item">
    select id, item_name, price, quantity
    from item
    where id = #{id}
</select>
  • 이제 SELECT문이다. 이 또한 보기가 너무 좋긴 하다. 더 말할게 없다. resultType은 이 SELECT문이 반환하는 객체 타입을 작성해주면 된다. "Item"이다. 원래는 이것도 풀 패키지 명을 작성해줘야 하는데 위에서 작성한 application.yaml 파일을 기억하는가? type-alias-package를 작성해줬기 때문에 그 하위에 있는 Item을 찾게 된다.

 

 

이제 JdbcTemplate에서 느꼈던 단점인 동적 쿼리를 이 MyBatis가 어떻게 해결해주는지 확인해보자.

<select id="findAll" resultType="Item">
    select id, item_name, price, quantity
    from item
    <where>
        <if test="itemName != null and itemName != ''">
            and item_name like concat('%', #{itemName}, '%')
        </if>
        <if test="maxPrice != null">
            and price &lt;= #{maxPrice}
        </if>
    </where>
</select>

 

  • 너무 간단하다. 작성하는 코드도 몇 줄 안된다. 다른건 볼 것 없고 <where></where> 여기만 보면 되는데, 일단 <where> 태그가 마법같은 일들을 3개나 해준다.
    • 안에 <if> 태그의 조건이 만족하는 게 없으면 WHERE절을 무시해버린다. 
    • 안에 <if> 태그의 조건이 하나라도 만족하면 WHERE절을 만들어준다.
    • WHERE절의 첫번째로 들어오는 문장에 "AND"가 있으면 지워준다.
  • 이 세 조건을 딱 보고 <where> 태그를 보면 모든게 다 이해가 될 것인데 다만 한가지 아쉬운 점은 xml파일이기 때문에 <, > 기호가 그대로 태그로 인식이 된다. 이 문제를 해결하려면 이 기호를 &lt;로 변환해줘야 한다. 

 

ItemRepository 구현체

package hello.itemservice.repository.mybatis;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
@RequiredArgsConstructor
public class MyBatisItemRepository implements ItemRepository {

    private final ItemMapper itemMapper;

    @Override
    public Item save(Item item) {
        itemMapper.save(item);
        return item;
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        itemMapper.update(itemId, updateParam);
    }

    @Override
    public Optional<Item> findById(Long id) {
        return itemMapper.findById(id);
    }

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        return itemMapper.findAll(cond);
    }
}

 

  • 여기서는 ItemRepository 구현체는 ItemMapper의 메소드를 가지고 위임만 해준다.

 

DTO

ItemSearchCond.java

package hello.itemservice.repository;

import lombok.Data;

@Data
public class ItemSearchCond {

    private String itemName;
    private Integer maxPrice;

    public ItemSearchCond() {
    }

    public ItemSearchCond(String itemName, Integer maxPrice) {
        this.itemName = itemName;
        this.maxPrice = maxPrice;
    }
}

 

ItemUpdateDto.java

package hello.itemservice.repository;

import lombok.Data;

@Data
public class ItemUpdateDto {
    private String itemName;
    private Integer price;
    private Integer quantity;

    public ItemUpdateDto() {
    }

    public ItemUpdateDto(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

 

Configuration

package hello.itemservice.config;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.jdbctemplate.JdbcTemplateItemRepositoryV3;
import hello.itemservice.repository.mybatis.ItemMapper;
import hello.itemservice.repository.mybatis.MyBatisItemRepository;
import hello.itemservice.service.ItemService;
import hello.itemservice.service.ItemServiceV1;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

@Configuration
@RequiredArgsConstructor
public class MyBatisConfig {

    private final ItemMapper itemMapper;

    @Bean
    public ItemService itemService() {
        return new ItemServiceV1(itemRepository());
    }

    @Bean
    public ItemRepository itemRepository() {
        return new MyBatisItemRepository(itemMapper);
    }
}

 

  • Configuration에서 한가지 짚고 넘어갈 건, DataSource 관련 코드가 한 줄도 없다. 이는 application.yml 파일에 정의한 DataSource 설정값들을 스프링이 알아서 ItemMapper랑 매핑해준다. 그래서 정의할 필요가 없다.

 

SpringBootApplication

package hello.itemservice;

import hello.itemservice.config.*;
import hello.itemservice.repository.ItemRepository;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Profile;


@Import(MyBatisConfig.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {

	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
	}
}

 

  • 여기서 MyBatisConfig를 컴포넌트 스캔을 통해 스프링이 알아서 등록하게 해줄 수 있지만, 이 예제에서는 컴포넌트 스캔 대상에 제외했기 떄문에 Config 파일을 임포트해서 스프링이 띄워질 때 빈으로 등록해줘야 한다.

 

 

테스트 코드

이제 실행해보자. 😊

package hello.itemservice.domain;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import hello.itemservice.repository.memory.MemoryItemRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Commit;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.DefaultTransactionAttribute;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@Transactional
@SpringBootTest
class ItemRepositoryTest {

    @Autowired
    ItemRepository itemRepository;

    @Test
    void save() {
        //given
        Item item = new Item("itemA", 10000, 10);

        //when
        Item savedItem = itemRepository.save(item);

        //then
        Item findItem = itemRepository.findById(item.getId()).get();
        assertThat(findItem).isEqualTo(savedItem);
    }

    @Test
    void updateItem() {
        //given
        Item item = new Item("item1", 10000, 10);
        Item savedItem = itemRepository.save(item);
        Long itemId = savedItem.getId();

        //when
        ItemUpdateDto updateParam = new ItemUpdateDto("item2", 20000, 30);
        itemRepository.update(itemId, updateParam);

        //then
        Item findItem = itemRepository.findById(itemId).get();
        assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
        assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
        assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
    }

    @Test
    void findItems() {
        //given
        Item item1 = new Item("itemA-1", 10000, 10);
        Item item2 = new Item("itemA-2", 20000, 20);
        Item item3 = new Item("itemB-1", 30000, 30);

        itemRepository.save(item1);
        itemRepository.save(item2);
        itemRepository.save(item3);

        //둘 다 없음 검증
        test(null, null, item1, item2, item3);
        test("", null, item1, item2, item3);

        //itemName 검증
        test("itemA", null, item1, item2);
        test("temA", null, item1, item2);
        test("itemB", null, item3);

        //maxPrice 검증
        test(null, 10000, item1);

        //둘 다 있음 검증
        test("itemA", 10000, item1);
    }

    void test(String itemName, Integer maxPrice, Item... items) {
        List<Item> result = itemRepository.findAll(new ItemSearchCond(itemName, maxPrice));
        assertThat(result).containsExactly(items); // containsExactly는 순서도 다 맞아야한다
    }
}

 

 

MyBatis 분석

사용법은 굉장히 간단했고, 궁금한 부분은 Mapper 인터페이스만 만들었을 뿐인데 구현체를 어떻게 만들었을까?에 대한 고민을 할 차례다.

ItemMapper 

package hello.itemservice.repository.mybatis;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.util.List;
import java.util.Optional;

@Mapper
public interface ItemMapper {

    void save(Item item);

    void update(@Param("id") Long id, @Param("updateParam") ItemUpdateDto updateParam);

    Optional<Item> findById(Long id);

    List<Item> findAll(ItemSearchCond itemSearchCond);
}

 

아까 위에서도 살짝 얘기했지만, Mybatis 스프링 연동 모듈에서 저 @Mapper 애노테이션이 달려있는 것들을 다 찾아서, 프록시를 만들고 그 프록시를 스프링 컨테이너에 빈으로 등록해준다. 이 과정에서 정말 여러가지 기술이 사용된다. 

  • 애노테이션 프로세싱
  • 리플렉션
  • JDK 동적 프록시 
  • 등등

이 세가지 내용 모두 내 블로그에서 다룬 내용들이다. 궁금하면 참고하면 좋을듯하다. 여튼 그림으로 보면 다음과 같다.

  • 애플리케이션 로딩 시점에, Mybatis 스프링 연동 모듈은 @Mapper가 붙어있는 인터페이스를 조사한다.
  • 해당 인터페이스가 발견되면 JDK 동적 프록시 기술을 사용해서 ItemMapper 인터페이스의 구현체를 만든다.
  • 생성된 구현체를 스프링 빈으로 등록한다.

이렇게 만들어진 Mapper의 구현체(프록시)는 심지어 예외 변환까지 스프링 데이터 접근 예외 추상화인 DataAccessException에 맞게 변환해서 반환해준다. JdbcTemplate이 제공하는 예외 변환 기능을 여기서도 제공한다고 이해하면 된다.

 

 

728x90
반응형
LIST
728x90
반응형
SMALL
SMALL

Spring과 데이터베이스를 연동할 때 사용되는 기술 중 여전히 잘 사용되는 기술인 JdbcTemplate을 사용하는 법을 정리하고자 한다.

우선, JdbcTemplate은 기존 JDBC 기술을 직접 사용할 때 겪는 문제들을 해결해 준다. 예를 들면 트랜잭션을 시작하고 종료하는 코드 작성이나 반복적인 커넥션 후처리와 같은 것들.

 

나는 개인적으로는 JdbcTemplate을 사용하지 않고 Spring Data JPAQuerydsl을 같이 사용하는 방식을 선호한다. 그러나, 이 JdbcTemplate은 알아둘 만한 가치가 있다고 생각해서 기록하고자 한다. 

 

우선, JDBC 기술을 직접 사용하면서 코드를 작성해보았다. 다음 포스팅에서 말이다.

 

[Renewal] JDBC 이해

참고자료 스프링 DB 1편 - 데이터 접근 핵심 원리 강의 | 김영한 - 인프런김영한 | 백엔드 개발에 필요한 DB 데이터 접근 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원

cwchoiit.tistory.com

 

 

[Renewal] 커넥션풀과 DataSource 이해

참고자료 스프링 DB 1편 - 데이터 접근 핵심 원리 강의 | 김영한 - 인프런김영한 | 백엔드 개발에 필요한 DB 데이터 접근 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원

cwchoiit.tistory.com

 

[Renewal] 트랜잭션, DB 락 이해

참고자료: 스프링 DB 1편 - 데이터 접근 핵심 원리 강의 | 김영한 - 인프런김영한 | 백엔드 개발에 필요한 DB 데이터 접근 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원

cwchoiit.tistory.com

 

[Renewal] 스프링의 트랜잭션

참고자료 스프링 DB 1편 - 데이터 접근 핵심 원리 강의 | 김영한 - 인프런김영한 | 백엔드 개발에 필요한 DB 데이터 접근 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원

cwchoiit.tistory.com

 

이 과정을 거치면서 JDBC를 직접 사용하면 비효율적인 부분이 같은 코드의 중복(try - catch, 커넥션 획득, 자원 반납 등)을 피할 수 없었다. 그래서 그 부분을 깔끔하게 해결해주는 스프링이 제공하는 JdbcTemplate을 사용해서 해결해보자.

 

라이브러리 다운로드

우선, JdbcTemplate 라이브러리를 받아야 한다. 

 

build.gradle

//JdbcTemplate
implementation 'org.springframework.boot:spring-boot-starter-jdbc'

 

build.gradle 파일에서 dependencies 추가하는 부분에 위 한 줄을 넣어주고 빌드를 다시 해주면 라이브러리를 내려받는다.

버전 명시는 따로 할 필요 없다. 스프링 부트는 현재 사용중인 스프링의 버전과 가장 호환이 잘 되는 버전을 알아서 선택해서 내려받아준다.

 

 

인터페이스 구현

이제 JdbcTemplate을 이용해서 DB와의 커뮤니케이션을 위한 인터페이스를 만들어야 한다.

이렇게 인터페이스와 구현체를 분리해서 추상화하면 추후 DB 접근 기술에 변경이 생겨도 비즈니스 로직에서의 코드 변경을 최소화할 수 있고 유지보수에 유리해진다.

 

ItemRepository.java

package hello.itemservice.repository;

import hello.itemservice.domain.Item;

import java.util.List;
import java.util.Optional;

public interface ItemRepository {

    Item save(Item item);

    void update(Long itemId, ItemUpdateDto updateParam);

    Optional<Item> findById(Long id);

    List<Item> findAll(ItemSearchCond cond);

}

 

우선 구현할 메소드는 4개이다. save(), update(), findById(), findAll().

여기서 짚고 넘어가야 할 건 update(Long itemId, ItemUpdateDto updateParam), findAll(ItemSearchCond cond) 메소드의 파라미터인 DTO 클래스들이다. 이 DTO 클래스의 위치를 두고 고민을 할 때가 있는데 DTO 클래스는 어떤 패키지에 있어야 할까?

딱 이것만 기억하기로 했다. 저 DTO 클래스의 사용하는 마지막 레벨이 어디인가?

만약, 저 DTO 클래스를 사용하는 마지막 레벨이 리포지토리 레벨이면 리포지토리 패키지에 클래스를 만들면 된다. 그게 아니라 만약 서비스 레벨이면 서비스 패키지에 클래스를 만들면 된다.

 

즉, 의존성 주입에 Circular dependency injection이 일어나지 않으면 된다. 만약 리포지토리에서 사용하는 DTO를 서비스 패키지에 만들어 두었다면 순환 의존성 주입 문제가 발생한다. 왜냐하면 컨트롤러 -> 서비스 -> 리포지토리 레벨로 호출이 되는데 서비스가 리포지토리를 호출하면서 의존 관계가 생기는데 리포지토리는 다시 서비스에게 의존해야 하는 (서비스 레벨에 DTO 클래스가 있으므로) 의존 관계 문제가 생긴다. 

 

그러니까 결국 DTO는 마지막으로 사용하는 레벨이 어디인가를 고려해서 패키지 위치를 결정하면 된다. 그게 아니라면, 여기저기서 다 사용되니 아예 dto를 위한 패키지를 따로 빼서 사용해도 좋다.

 

DTO

ItemUpdateDto.java

package hello.itemservice.repository;

import lombok.Data;

@Data
public class ItemUpdateDto {
    private String itemName;
    private Integer price;
    private Integer quantity;

    public ItemUpdateDto() {
    }

    public ItemUpdateDto(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

ItemSearchCond.java

package hello.itemservice.repository;

import lombok.Data;

@Data
public class ItemSearchCond {

    private String itemName;
    private Integer maxPrice;

    public ItemSearchCond() {
    }

    public ItemSearchCond(String itemName, Integer maxPrice) {
        this.itemName = itemName;
        this.maxPrice = maxPrice;
    }
}

 

ItemRepository 구현체 - JdbcTemplate 사용

package hello.itemservice.repository.jdbctemplate;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
import org.springframework.util.StringUtils;

import javax.sql.DataSource;
import java.util.List;
import java.util.Map;
import java.util.Optional;


@Slf4j
public class JdbcTemplateItemRepository implements ItemRepository {

    private final NamedParameterJdbcTemplate template;
    private final SimpleJdbcInsert jdbcInsert;

    public JdbcTemplateItemRepositoryV3(DataSource dataSource) {
        this.template = new NamedParameterJdbcTemplate(dataSource);
        this.jdbcInsert = new SimpleJdbcInsert(dataSource)
                .withTableName("item")
                .usingGeneratedKeyColumns("id");
    }

    @Override
    public Item save(Item item) {
        BeanPropertySqlParameterSource param = new BeanPropertySqlParameterSource(item);
        Number key = jdbcInsert.executeAndReturnKey(param);

        item.setId(key.longValue());
        return item;
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        String sql = "UPDATE item SET item_name = :itemName, price = :price, quantity = :quantity WHERE id = :id";

        SqlParameterSource param = new MapSqlParameterSource()
                .addValue("itemName", updateParam.getItemName())
                .addValue("price", updateParam.getPrice())
                .addValue("quantity", updateParam.getQuantity())
                .addValue("id", itemId);

        template.update(sql, param);
    }

    @Override
    public Optional<Item> findById(Long id) {
        String sql = "SELECT id, item_name, price, quantity FROM item WHERE id = :id";
        try {
            Map<String, Object> param = Map.of("id", id);
            Item item = template.queryForObject(sql, param, itemRowMapper());
            return Optional.of(item);
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }

    private RowMapper<Item> itemRowMapper() {
        return BeanPropertyRowMapper.newInstance(Item.class); // snake_case를 camelCase로 변환해주는 작업도 해줌
    }

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();

        BeanPropertySqlParameterSource param = new BeanPropertySqlParameterSource(cond);

        String sql = "SELECT id, item_name, price, quantity FROM item";

        // 동적 쿼리
        if (StringUtils.hasText(itemName) || maxPrice != null) {
            sql += " WHERE";
        }

        boolean andFlag = false;

        if (StringUtils.hasText(itemName)) {
            sql += " item_name LIKE concat('%', :itemName, '%')";
            andFlag = true;
        }

        if (maxPrice != null) {
            if (andFlag) {
                sql += " AND";
            }
            sql += " price <= :maxPrice";
        }

        log.info("sql={}", sql);

        return template.query(sql, param, itemRowMapper());
    }
}

 

위 코드는 구현체의 전체 코드이다. 하나씩 뜯어보자.

private final NamedParameterJdbcTemplate template;
private final SimpleJdbcInsert jdbcInsert;

 

NamedParameterJdbcTemplate 클래스는 JdbcTemplate을 사용하지만, 파라미터를 순서에 맞게 작성해야하는 불편함을 해결하기 위해 이름에 따른 파라미터 전달을 가능하게 해주는 NamedParameterJdbcTemplate를 사용했다.

 

SimpleJdbcInsertINSERT 쿼리를 좀 더 간단하게 사용할 수 있게 도와주는 클래스라고 보면 된다. 그러니까 JdbcTemplate을 사용할 때 INSERT 쿼리 작성을 하지 않아도 되고, PK를 auto generated key로 설정한 경우 생성한 새로운 레코드의 키를 KeyHolder에 담고 돌려주고 하는 번거로운 작업을 대신해준다.

 

public JdbcTemplateItemRepositoryV3(DataSource dataSource) {
    this.template = new NamedParameterJdbcTemplate(dataSource);
    this.jdbcInsert = new SimpleJdbcInsert(dataSource)
            .withTableName("item")
            .usingGeneratedKeyColumns("id");
}

 

생성자 부분을 보자. 우선 DataSource를 파라미터로 받아 NamedParameterJdbcTemplate()SimpleJdbcInsert()에 각각 넣어준다. SimpleJdbcInsert는 오로지 INSERT만을 위한 편의성을 제공해주는 녀석이다. 그래서 어떤 테이블에 INSERT를 할지 알려주어야 한다. 그래서 withTableName()"item"이라는 테이블을 넣어주었고, usingGeneratedKeyColums()에는 "id"를 넣어주었다. 이건 기본키 자동 생성 옵션으로 테이블을 만들었다면 그 키 이름을 알려주어야 하기 때문에 작성했다.

 

@Override
public Item save(Item item) {
    BeanPropertySqlParameterSource param = new BeanPropertySqlParameterSource(item);
    Number key = jdbcInsert.executeAndReturnKey(param);

    item.setId(key.longValue());
    return item;
}

 

save(Item item) 메소드를 확인해 보자. 이 메소드는 새로운 Item 레코드 하나를 추가할 때 사용된다. 여기서 위에서 말한 SimpleJdbcInsert가 사용될 거고 전달해 주는 파라미터는 Item을 생성할 때 필요한 파라미터 (itemName, price, quantity)가 전달된다. 근데 그때 사용되는 파라미터는 BeanPropertySqlParameterSource라는 클래스인데 이 클래스에 item 객체를 넘기면 이 item 객체가 가지고 있는 필드값을 그대로 파라미터에 필드 이름을 기준으로 알아서 넣어준다. 즉, 사실 저 두 줄은 다음과 같다.

String sql = "INSERT INTO item(item_name, price, quantity) values (:itemName, :price, :quantity)";

 

INSERT SQL문에 :itemName, :price, :quantity에 각각 필드값이 들어간다. (item에 들어있는)

그리고 SimpleJdbcInsert.executeAndReturnKey() 메소드의 반환값은 INSERT문으로 생성된 새로운 레코드의 키를 반환한다.

 

@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
    String sql = "UPDATE item SET item_name = :itemName, price = :price, quantity = :quantity WHERE id = :id";

    SqlParameterSource param = new MapSqlParameterSource()
            .addValue("itemName", updateParam.getItemName())
            .addValue("price", updateParam.getPrice())
            .addValue("quantity", updateParam.getQuantity())
            .addValue("id", itemId);

    template.update(sql, param);
}

 

update() 메소드를 보자. UPDATE SQL문을 작성하고 그 작성한 SQL에 필요한 파라미터를 MapSqlParameterSource를 이용해서 넘겨준다. 보기만 해도 딱 간단하고 명료하다. addValue()key, value 값을 차례대로 넣어주면 된다. 위에서는 BeanPropertySqlParameterSource를 사용했는데 여기서는 MapSqlParameterSource를 사용했다. 이렇게도 사용할 수 있다는 것을 보여주기 위해 사용한 것 뿐이다.

 

@Override
public Optional<Item> findById(Long id) {
    String sql = "SELECT id, item_name, price, quantity FROM item WHERE id = :id";
    try {
        Map<String, Object> param = Map.of("id", id);
        Item item = template.queryForObject(sql, param, itemRowMapper());
        return Optional.of(item);
    } catch (EmptyResultDataAccessException e) {
        return Optional.empty();
    }
}

 

이번엔 findById() 메소드이다. 여기서도 마찬가지로 SQL문을 작성해 주고 그 SQL에 필요한 파라미터를 넘겨주면 되는데, 여기서는 다른 방법을 사용해 봤다. 물론 위에서 사용한 MapSqlParameterSource를 사용해도 된다. 

딱 한 개의 파라미터가 필요하니까 HashMap을 만들 필요 없이 바로 Map.of("id", id)로 파라미터를 만들어주고 넘겨주면 된다.

 

이때, itemRowMapper()를 호출하는데 이는 SELECT SQL문을 날려서 반환되는 레코드를 Item 객체로 변환해 주는 메서드이다.

private RowMapper<Item> itemRowMapper() {
    return BeanPropertyRowMapper.newInstance(Item.class); // snake_case를 camelCase로 변환해주는 작업도 해줌
}

 

아주 간단하다. BeanPropertyRowMapper로 변환하고자 하는 클래스를 넘겨주면 된다. 이 녀석이 반환된 ResultSet의 값을 이용해 item 객체를 만들어준다. 그리고 이 한 줄의 코드는 아래 코드를 축약했다고 보면 된다.

private RowMapper<Item> itemRowMapper() {
    return ((rs, rowNum) -> {
        Item item = new Item();
        item.setId(rs.getLong("id"));
        item.setItemName(rs.getString("item_name"));
        item.setPrice(rs.getInt("price"));
        item.setQuantity(rs.getInt("quantity"));
        return item;
    });
}

 

@Override
public List<Item> findAll(ItemSearchCond cond) {
    String itemName = cond.getItemName();
    Integer maxPrice = cond.getMaxPrice();

    BeanPropertySqlParameterSource param = new BeanPropertySqlParameterSource(cond);

    String sql = "SELECT id, item_name, price, quantity FROM item";

    // 동적 쿼리
    if (StringUtils.hasText(itemName) || maxPrice != null) {
        sql += " WHERE";
    }

    boolean andFlag = false;

    if (StringUtils.hasText(itemName)) {
        sql += " item_name LIKE concat('%', :itemName, '%')";
        andFlag = true;
    }

    if (maxPrice != null) {
        if (andFlag) {
            sql += " AND";
        }
        sql += " price <= :maxPrice";
    }

    log.info("sql={}", sql);

    return template.query(sql, param, itemRowMapper());
}

 

이제 findAll() 메소드를 보자. 이 부분이 JdbcTemplate의 단점이라고 생각하면 된다. 즉, 동적 쿼리를 만들어내기 쉽지 않다는 것.

우선, BeanPropertySqlParameterSource를 생성해 파라미터 바인딩을 해준다. ItemSearchCond를 넘겼을 때 이 객체가 가지고 있는 필드 이름을 통해 파라미터 바인딩을 해줄 것이다. 

if (StringUtils.hasText(itemName) || maxPrice != null) {
    sql += " WHERE";
}

 

이 부분에서 itemName이나 maxPrice가 있다면 위에 만들어놓은 SQL문에 WHERE 절을 붙인다.

 

boolean andFlag = false;

if (StringUtils.hasText(itemName)) {
    sql += " item_name LIKE concat('%', :itemName, '%')";
    andFlag = true;
}

if (maxPrice != null) {
    if (andFlag) {
        sql += " AND";
    }
    sql += " price <= :maxPrice";
}

 

이 부분에서 itemNamemaxPrice를 각각 구분하여 WHERE절에 조건을 넣어주는데, 이제 SQL문에 각각 조건을 추가하면 된다. 근데 이게 여간 귀찮은 게 아니다. 문자열마다 공백도 신경 써야 하고, 있는지 없는지 판단해야 하는 조건문이나 AND를 추가하고 말고까지 다 생각해야 하니 이런 부분에서 JdbcTemplate의 단점이 드러난다고 볼 수 있다. 

 

JdbcTemplateConfig

package hello.itemservice.config;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.jdbctemplate.JdbcTemplateItemRepository;
import hello.itemservice.service.ItemService;
import hello.itemservice.service.ItemServiceV1;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

@Configuration
@RequiredArgsConstructor
public class JdbcTemplateV3Config {

    private final DataSource dataSource;

    @Bean
    public ItemService itemService() {
        return new ItemServiceV1(itemRepository());
    }

    @Bean
    public ItemRepository itemRepository() {
        return new JdbcTemplateItemRepository(dataSource);
    }
}

 

물론, 이 Config 파일을 만들어 직접 빈으로 등록하는 게 아니라 컴포넌트 스캔을 사용해도 무방하다.

 

SpringBootApplication

package hello.itemservice;

import hello.itemservice.config.*;
import hello.itemservice.repository.ItemRepository;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Profile;


@Import(JdbcTemplateV3Config .class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {

	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
	}
}

 

@Import 애노테이션으로 Config 파일을 Import 한 이유는 컴포넌트 스캔의 패키지가 해당 파일을 포함하지 않기 때문이다. 위 코드에서 보다시피 scanBasePackagesJdbcTemplateV3Config 파일이 포함된 패키지를 포함하지 않는다. 이건 크게 중요하지 않다. 아까 말했듯, 그냥 컴포넌트 스캔으로 해도 된다. 이런 방법도 있다라는 것을 보여주는 것 뿐이다.

 

ItemService

package hello.itemservice.service;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;

import java.util.List;
import java.util.Optional;

public interface ItemService {

    Item save(Item item);

    void update(Long itemId, ItemUpdateDto updateParam);

    Optional<Item> findById(Long id);

    List<Item> findItems(ItemSearchCond itemSearch);
}

ItemService 구현체

package hello.itemservice.service;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class ItemServiceV1 implements ItemService {

    private final ItemRepository itemRepository;

    @Override
    public Item save(Item item) {
        return itemRepository.save(item);
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        itemRepository.update(itemId, updateParam);
    }

    @Override
    public Optional<Item> findById(Long id) {
        return itemRepository.findById(id);
    }

    @Override
    public List<Item> findItems(ItemSearchCond cond) {
        return itemRepository.findAll(cond);
    }
}

 

구현체는 리포지토리의 위임만 하고 있다. 사실상 이렇게 위임만 하는 경우 서비스가 필요 없을 수 있다 프로젝트에 따라. 그러나 구조를 좀 체계적으로 만들기 위해 서비스까지 작성했다. 이제 실제로 이 코드를 수행해 보자. 그리고 보통은, 서비스는 인터페이스를 굳이 만들지 않아도 된다. 그 이유는, 음.. 서비스 코드는 순수한 비즈니스 로직이 담겨있는 코드이다. 여기는 최대한 어떤 특정 기술에 종속적이게 작성하지 말고 순수한 자바 코드로 작성해야 한다. 물론 상황에 따라 다르겠지만. 그래서 사용하는 기술을 갈아끼우는게 아니라 순수한 서비스 코드를 만든다면 구현체를 갈아끼울 이유가 없다. 사실. 

 

 

테스트 코드

package hello.itemservice.domain;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import hello.itemservice.repository.memory.MemoryItemRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Commit;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.DefaultTransactionAttribute;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@Transactional
@SpringBootTest
class ItemRepositoryTest {

    @Autowired
    ItemRepository itemRepository;

    @Test
    void save() {
        //given
        Item item = new Item("itemA", 10000, 10);

        //when
        Item savedItem = itemRepository.save(item);

        //then
        Item findItem = itemRepository.findById(item.getId()).get();
        assertThat(findItem).isEqualTo(savedItem);
    }

    @Test
    void updateItem() {
        //given
        Item item = new Item("item1", 10000, 10);
        Item savedItem = itemRepository.save(item);
        Long itemId = savedItem.getId();

        //when
        ItemUpdateDto updateParam = new ItemUpdateDto("item2", 20000, 30);
        itemRepository.update(itemId, updateParam);

        //then
        Item findItem = itemRepository.findById(itemId).get();
        assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
        assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
        assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
    }

    @Test
    void findItems() {
        //given
        Item item1 = new Item("itemA-1", 10000, 10);
        Item item2 = new Item("itemA-2", 20000, 20);
        Item item3 = new Item("itemB-1", 30000, 30);

        itemRepository.save(item1);
        itemRepository.save(item2);
        itemRepository.save(item3);

        //둘 다 없음 검증
        test(null, null, item1, item2, item3);
        test("", null, item1, item2, item3);

        //itemName 검증
        test("itemA", null, item1, item2);
        test("temA", null, item1, item2);
        test("itemB", null, item3);

        //maxPrice 검증
        test(null, 10000, item1);

        //둘 다 있음 검증
        test("itemA", 10000, item1);
    }

    void test(String itemName, Integer maxPrice, Item... items) {
        List<Item> result = itemRepository.findAll(new ItemSearchCond(itemName, maxPrice));
        assertThat(result).containsExactly(items); // containsExactly는 순서도 다 맞아야한다
    }
}

 

 

728x90
반응형
LIST

'Spring + Database' 카테고리의 다른 글

[Renewal] Spring Data JPA  (4) 2024.12.07
[Renewal] MyBatis  (4) 2024.12.06
[Renewal] 테스트 시 데이터베이스 연동  (0) 2024.12.05
[Renewal] 예외2, 스프링의 데이터접근 예외 추상화  (3) 2024.12.05
[Renewal] 예외  (0) 2024.12.05
728x90
반응형
SMALL

참고자료

 

스프링 DB 2편 - 데이터 접근 활용 기술 강의 | 김영한 - 인프런

김영한 | 백엔드 개발에 필요한 DB 데이터 접근 기술을 활용하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니다., 백엔드

www.inflearn.com

 

스프링 프로젝트에서 데이터베이스에 연동하는 테스트에 대해 알아보는 포스팅이다. 데이터 접근 기술은 실제 데이터베이스에 접근해서 데이터를 잘 저장하고 조회할 수 있는지 확인하는 것이 필요하다. 지금부터 테스트를 실행할 때 실제 데이터베이스를 연동해서 진행해보자. 

 

우선, main 하위에 있는 application.yamltest 하위에 있는 application.yaml이 나뉘어져 있는데, 그 이유는 테스트 케이스는 src/test 하위에 있기 때문에 실행하면 src/test에 있는 application.yaml 파일이 우선순위를 가지고 실행된다. 그래서 일단 이 파일을 다음과 같이 수정해보자. 

spring:
  profiles:
    active: test

  datasource:
    url: jdbc:h2:tcp://localhost/~/h2/db/springdb
    username: sa
    password:
  • 이렇게 설정을 하면 데이터베이스는 H2 데이터베이스를 사용하는 것이고, 로컬 DB에 있는 데이터베이스로 테스트를 하겠다는 의미가 된다.

테스트 실행

@SpringBootTest
class ItemRepositoryTest {...}
  • 가장 먼저 이 @SpringBootTest 애노테이션이 달려있음을 확인할 수 있는데 이 애노테이션은 @SpringBootApplication을 찾아서 설정으로 사용한다.
  • 그러니까 쉽게 말해서, 우리의 main 하위에 있는 스프링 애플리케이션을 사용한다고 보면 된다. 실제로 스프링 컨테이너도 띄워지고 우리가 작업했던 설정 데이터들을 사용해 빈으로 등록하고 한다.

ItemRepositoryTest - 전체 소스

package hello.itemservice.domain;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import hello.itemservice.repository.memory.MemoryItemRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class ItemRepositoryTest {

    @Autowired
    ItemRepository itemRepository;

    @AfterEach
    void afterEach() {
        //MemoryItemRepository 의 경우 제한적으로 사용
        if (itemRepository instanceof MemoryItemRepository) {
            ((MemoryItemRepository) itemRepository).clearStore();
        }
    }

    @Test
    void save() {
        //given
        Item item = new Item("itemA", 10000, 10);

        //when
        Item savedItem = itemRepository.save(item);

        //then
        Item findItem = itemRepository.findById(item.getId()).get();
        assertThat(findItem).isEqualTo(savedItem);
    }

    @Test
    void updateItem() {
        //given
        Item item = new Item("item1", 10000, 10);
        Item savedItem = itemRepository.save(item);
        Long itemId = savedItem.getId();

        //when
        ItemUpdateDto updateParam = new ItemUpdateDto("item2", 20000, 30);
        itemRepository.update(itemId, updateParam);

        //then
        Item findItem = itemRepository.findById(itemId).get();
        assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
        assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
        assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
    }

    @Test
    void findItems() {
        //given
        Item item1 = new Item("itemA-1", 10000, 10);
        Item item2 = new Item("itemA-2", 20000, 20);
        Item item3 = new Item("itemB-1", 30000, 30);

        itemRepository.save(item1);
        itemRepository.save(item2);
        itemRepository.save(item3);

        //둘 다 없음 검증
        test(null, null, item1, item2, item3);
        test("", null, item1, item2, item3);

        //itemName 검증
        test("itemA", null, item1, item2);
        test("temA", null, item1, item2);
        test("itemB", null, item3);

        //maxPrice 검증
        test(null, 10000, item1);

        //둘 다 있음 검증
        test("itemA", 10000, item1);
    }

    void test(String itemName, Integer maxPrice, Item... items) {
        List<Item> result = itemRepository.findAll(new ItemSearchCond(itemName, maxPrice));
        assertThat(result).containsExactly(items);
    }
}
  • 이 테스트 케이스 3개를 실행해보면 다음과 같은 결과를 얻을 것이다.

실행 결과

  • updateItem() - 성공
  • save() - 성공
  • findItems() - 실패

findItems()는 실패했을까? 이유는 간단하다. updateItem(), save()를 실행하면서 생긴 데이터를 데이터베이스가 그대로 가지고 있기 때문에 findItems() 테스트를 실행할 때 예상하는 데이터 개수가 다르기 때문이다. 그러니까 결론적으로 테스트 시 데이터베이스는 운영이나 로컬 데이터베이스와는 철저히 분리를 해야 하며, 분리한 데이터베이스로 테스트를 할때 테스트 후 데이터를 남겨두면 안된다!

 

테스트 - 데이터베이스 분리

가장 원초적인 방법으로는, 테스트용 데이터베이스를 새로 만드는 것이다. 따라서 H2 데이터베이스를 하나 더 만들면 된다.

그래서 나는 다음과 같이 application.yaml 파일을 수정했다.

spring:
  profiles:
    active: test

  datasource:
    url: jdbc:h2:tcp://localhost/~/h2/db/springdbtest
    username: sa
    password:
  • 로컬 데이터베이스는 `springdb` 였다면, 테스트 데이터베이스는 `springdbtest`이다.
  • 그리고 아래와 같이 테스트에 필요한 item 테이블 하나를 만든다.
 create table item
 (
     id        bigint generated by default as identity,
     item_name varchar(10),
     price     integer,
     quantity  integer,
     primary key (id)
 );

 

이렇게 변경하고 테스트를 전체 실행해보자. 과연 잘될까? 물론 아니다.

 

당연하게도 로컬 데이터베이스와 테스트 데이터베이스는 분리해야 하는건 맞지만, 분리한 데이터베이스인 테스트용 데이터베이스 역시 여러 테스트가 진행된다면 데이터가 남아있게 된다. 즉, 테스트에서 매우 중요한 원칙은 다음과 같다.

  • 테스트는 다른 테스트와 격리해야 한다.
  • 테스트는 반복해서 실행할 수 있어야 한다.

그런데 지금은 findItems()라는 테스트는 updateItem(), save() 둘 다와 격리성이 없다. 왜냐하면 같은 데이터베이스를 사용하는데다가 테스트를 실행하고 생긴 데이터가 그대로 남아있으니 말이다. 물론, 테스트가 끝날때마다 DELETE 쿼리를 날리면 해결될 수도 있는데 이건 근본적인 해결책이 아니다. 만약 테스트 과정에서 이 쿼리에 대한 오류가 생기면 테스트가 끝나고 이 쿼리가 제대로 수행되지 않을 수 있기 때문이다. 그래서 이 문제를 해결하는 방법은 데이터 롤백을 하는것이다.

 

테스트 - 데이터 롤백

테스트가 끝나고 나서 트랜잭션을 강제로 롤백해버리면 데이터가 깔끔하게 제거된다. 테스트를 하면서 데이터를 이미 저장했는데, 중간에 테스트가 실패해서 롤백을 호출하지 못해도 괜찮다. 트랜잭션을 커밋하지 않았기 때문에 데이터베이스에 해당 데이터가 반영되지 않는다. 이렇게 트랜잭션을 활용하면 테스트가 끝나고 나서 데이터를 깔끔하게 원래 상태로 되돌릴 수 있다.

 

그리고 테스트는 각각의 테스트 실행 전 후로 동작하는 @BeforeEach, @AfterEach 라는 편리한 기능을 제공한다.

 

ItemRepositoryTest - 롤백 코드 추가

@SpringBootTest
class ItemRepositoryTest {

    @Autowired
    ItemRepository itemRepository;

    @Autowired
    PlatformTransactionManager transactionManager;
    TransactionStatus transaction;

    @BeforeEach
    void beforeEach() {
         transaction = transactionManager.getTransaction(new DefaultTransactionDefinition());
    }

    @AfterEach
    void afterEach() {
        //MemoryItemRepository 의 경우 제한적으로 사용
        if (itemRepository instanceof MemoryItemRepository) {
            ((MemoryItemRepository) itemRepository).clearStore();
        }

        transactionManager.rollback(transaction);
    }
}
  • 위 코드처럼 @BeforeEach 애노테이션이 달리면, 각 테스트 마다마다 테스트 실행 전에, 작성한 코드를 수행해주는데 그게 트랜잭션 시작을 하는 코드이다.
  • 위 코드처럼 @AfterEach 애노테이션이 달리면, 각 테스트 마다마다 테스트 실행 후에, 작성한 코드를 수행해주는데 그게 바로 롤백이다. 
  • 이렇게 @BeforeEach, @AfterEach를 사용해서, 테스트 실행 전 후로 트랜잭션을 시작하고 롤백을 해주면 데이터가 테스트 후에 남지 않게 된다.

 

이 상태로 테스트를 실행하면 계속 실행해도 테스트에 통과하게 된다. 그런데 여전히 불편한 건 뭐냐면 테스트 코드 파일 마다 이걸 이렇게 귀찮게 작성해줘야 할까?에 대한 고민이다. 스프링은 우리에게 이런 불편함을 그대로 떠안도록 내버려 두지 않는다. 

 

테스트 - @Transactional

스프링은 테스트 데이터 초기화를 위해 트랜잭션을 적용하고 롤백하는 방식을 @Transactional 애노테이션 하나로 깔끔하게 해결해준다. 위에서 사용했던 직접 트랜잭션을 시작하고 롤백하는 코드를 다 날려도 된다.

@SpringBootTest
@Transactional
class ItemRepositoryTest {

    @Autowired
    ItemRepository itemRepository;

    @AfterEach
    void afterEach() {
        //MemoryItemRepository 의 경우 제한적으로 사용
        if (itemRepository instanceof MemoryItemRepository) {
            ((MemoryItemRepository) itemRepository).clearStore();
        }
    }
    
    ...
}
  • 이렇게 클래스 레벨에 @Transactional 애노테이션 하나만 붙여주고 아까 위에서 작성했던 직접 트랜잭션을 시작하고 롤백하는 코드는 다 날려버렸다.
  • 참고로 org.springframework.transaction.annotation.Transactional 패키지를 사용해야 한다.
  • 이렇게 하기만 하면 끝이다. 이대로 테스트를 계속 실행해도 계속 통과한다. 자동으로 롤백이 된다.

 

원래, 메인 코드에서 @Transactional 애노테이션이 달리면 정상적으로 끝나면 커밋이 맞다. 근데 테스트 코드에서는 기본값이 롤백이다. 스프링이 우릴 위해 편리하게 사용할 수 있도록 이렇게 설계한 것이다.

 

정리하자면

  • @Transactional은 테스트가 끝난 후 개발자가 직접 데이터를 삭제하지 않아도 되는 편리함을 제공한다.
  • 테스트 실행 중에 데이터를 등록하고 중간에 테스트가 강제로 종료되어도 걱정이 없다. 이 경우, 트랜잭션 커밋을 하지 않기 때문에 데이터는 자동으로 롤백된다. (보통 데이터베이스 커넥션이 끊어지면 자동으로 롤백되어 버린다)
  • 트랜잭션 범위 안에서 테스트를 진행하기 때문에 동시에 다른 테스트가 진행되어도 서로 영향을 주지 않는 장점이 있다. 
  • @Transactional 애노테이션 덕분에 아주 편리하게 다음 원칙을 지킬 수 있게 되었다.
    • 테스트는 다른 테스트와 격리해야 한다.
    • 테스트는 반복해서 실행할 수 있어야 한다. 

 

테스트 시 강제로 커밋 - @Commit

@Transactional을 테스트에서 사용하면 테스트가 끝나면 바로 롤백되기 때문에 테스트 과정에서 저장한 모든 데이터가 사라진다. 당연히 이렇게 되는게 맞다. 그런데 가끔은 데이터베이스에 데이터가 잘 보관되었는지 최종 결과를 눈으로 확인하고 싶을때도 있다. 이럴때는 다음과 같이 @Commit 애노테이션을 클래스 또는 메서드에 붙이면 테스트 종료 후 롤백 대신 커밋이 호출된다.

@Test
@Commit
void save() {
    //given
    Item item = new Item("itemA", 10000, 10);

    //when
    Item savedItem = itemRepository.save(item);

    //then
    Item findItem = itemRepository.findById(item.getId()).get();
    assertThat(findItem).isEqualTo(savedItem);
}

 

테스트 - 임베디드 모드 DB

테스트 케이스를 실행하기 위해서 별도의 데이터베이스를 설치하고, 운영하는 것은 상당히 번잡한 작업이다. 단순히 테스트를 검증할 용도로만 사용하기 때문에 테스트가 끝나면 데이터베이스와 데이터를 모두 삭제해도 된다. 더 나아가서 테스트가 끝나면 데이터베이스 자체를 제거해도 된다. 

 

임베디드 모드

H2 데이터베이스는 자바로 개발되어 있고, JVM안에서 메모리 모드로 동작하는 특별한 기능을 제공한다. 그래서 애플리케이션을 실행할때 H2 데이터베이스도 해당 JVM 메모리에 포함해서 함께 실행할 수 있다. DB를 애플리케이션에 내장해서 함께 실행한다고 해서 임베디드 모드라 한다. 물론 애플리케이션이 종료되면 임베디드 모드로 동작하는 H2 데이터베이스도 함께 종료되고, 데이터도 모두 사라진다. 쉽게 이야기해서 애플리케이션에서 자바 메모리를 함께 사용하는 라이브러리처럼 동작하는 것이다. 

 

이제 H2 데이터베이스를 임베디드 모드로 사용해보자.

 

임베디드 모드 직접 사용

임베디드 모드를 직접 사용하는 방법은 다음과 같다.

ItemServiceApplication

package hello.itemservice;

import hello.itemservice.config.*;
import hello.itemservice.repository.ItemRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Profile;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

import javax.sql.DataSource;


@Slf4j
// @Import(MemoryConfig.class)
// @Import(JdbcTemplateV1Config.class)
// @Import(JdbcTemplateV2Config.class)
@Import(JdbcTemplateV3Config.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {

	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
	}

	@Bean
	@Profile("local")
	public TestDataInit testDataInit(ItemRepository itemRepository) {
		return new TestDataInit(itemRepository);
	}

	@Bean
	@Profile("test")
	public DataSource dataSource() {
		log.info("메모리 데이터베이스 초기화");
		DriverManagerDataSource dataSource = new DriverManagerDataSource();
		dataSource.setDriverClassName("org.h2.Driver");
		dataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1");
		dataSource.setUsername("sa");
		dataSource.setPassword("");
		return dataSource;
	}
}
  • 이 부분에서 주의깊게 볼 부분은 다음 부분이다.
@Bean
@Profile("test")
public DataSource dataSource() {
    log.info("메모리 데이터베이스 초기화");
    DriverManagerDataSource dataSource = new DriverManagerDataSource();
    dataSource.setDriverClassName("org.h2.Driver");
    dataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1");
    dataSource.setUsername("sa");
    dataSource.setPassword("");
    return dataSource;
}
  • @Profile("test") 애노테이션으로 현재 프로파일이 `test`인 경우에만 이 빈은 등록된다. 테스트 케이스에서만 이 데이터소스를 스프링 빈으로 등록해서 사용하겠다는 뜻이 된다.
  • 이때 DataSource를 빈으로 등록하는데 URL 부분을 자세히 보면 `jdbc:h2:mem:...` 으로 되어 있다. memmemory를 의미한다. 즉, 메모리에 H2 데이터베이스를 띄우겠다는 의미이다. DB_CLOSE_DELAY=-1이 의미하는건, 임베디드 모드에서는 데이터베이스 커넥션 연결이 모두 끊어지면 데이터베이스도 종료되는데 그것을 방지하는 설정이다.

이제 테스트 케이스를 실행해보면 된다. 근데! 그 전에, 애플리케이션이 뜨는 시점에 메모리에 H2 데이터베이스를 띄우는데 우리가 원하는 테이블이 만들어질까? 아니다. 메모리에 띄운 H2 데이터베이스에서 사용할 테이블도 정의를 해줘야 한다. 그것은 `src/test/resources` 하위에 다음과 같은 파일로 정의할 수 있다.

schema.sql

drop table if exists item CASCADE;
create table item
(
    id        bigint generated by default as identity,
    item_name varchar(10),
    price     integer,
    quantity  integer,
    primary key (id)
);
  • 내가 원하는 테이블은 딱 이렇게 생긴 item 테이블 하나이다.
  • 이렇게 파일도 만들었으면 이제 테스트를 마음껏 실행할 수 있다.

테스트는 실행해보면 결과를 확인할 수 있을 것이고, 그건 그렇고, 이 작업 과연 개발자가 직접 해줘야할까?

테스트 - 스프링 부트와 임베디드 모드

스프링 부트는 개발자에게 정말 많은 편리함을 제공하는데, 임베디드 데이터베이스에 대한 설정도 기본으로 제공한다. 그래서 스프링 부트는 데이터베이스에 대한 별다른 설정이 없으면 기본으로 임베디드 데이터베이스를 사용한다. 이건 테스트나 메인 소스나 동일하다. 

 

그래서 위에서 작업했던 이 부분 주석처리해보자.

ItemServiceApplication  일부분

/*@Bean
@Profile("test")
public DataSource dataSource() {
    log.info("메모리 데이터베이스 초기화");
    DriverManagerDataSource dataSource = new DriverManagerDataSource();
    dataSource.setDriverClassName("org.h2.Driver");
    dataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1");
    dataSource.setUsername("sa");
    dataSource.setPassword("");
    return dataSource;
}*/

 

그리고 테스트 하위에 있는 application.yaml 파일에서도 datasource 관련 내용을 다 주석처리하자.

src/test/resources/application.yaml 일부분

#  datasource:
#    url: jdbc:h2:tcp://localhost/~/h2/db/springdbtest
#    username: sa
#    password:

 

이렇게 까지 하면, 데이터베이스에 대한 아무런 내용도 지금 없는 상태이다. 그러면 스프링 부트는 기본으로 임베디드 데이터베이스를 사용한다. (당연히, 테이블 정의에 대한 DDL 스크립트, 즉, schema.sql은 살려두어야 한다)

 

이 상태로 테스트 실행해보자. 정상적으로 잘 진행될 것이다.

 

결론

결국, 결론은 그냥 테스트할 때 데이터베이스와 연동이 필요하면 그냥 스프링 부트가 제공하는 임베디드 데이터베이스를 사용하면 된다. 데이터베이스를 별도로 만들 필요도 없고, 그저 테스트를 위한 것이기 때문에 완전히 안성맞춤이다. 그리고 테이블 정의에 대한 작업만 해주면 되는데 이 부분도 나중에 ORM을 사용하면 그마저도 필요없다. ORM으로 테이블 매핑을 다 해놓으면 그냥 그대로 동작하기 때문에. 

 

728x90
반응형
LIST

'Spring + Database' 카테고리의 다른 글

[Renewal] MyBatis  (4) 2024.12.06
[Renewal] JdbcTemplate  (8) 2024.12.06
[Renewal] 예외2, 스프링의 데이터접근 예외 추상화  (3) 2024.12.05
[Renewal] 예외  (0) 2024.12.05
[Renewal] 스프링의 트랜잭션  (0) 2024.11.24
728x90
반응형
SMALL

https://cwchoiit.tistory.com/68

 

예외

자바에서 예외는 크게 두 가지(체크 예외, 언체크 예외)로 나뉘어진다. 체크 예외 컴파일러가 체크하는 예외이다. 체크 예외는 잡아서 처리하거나 또는 밖으로 던지도록 선언해야한다. 그렇지

cwchoiit.tistory.com

이전 포스팅인 1편에서 기본적인 예외의 내용을 알아보았고 이제는 스프링과 관련된 내용을 작성하고자 한다.

 

만약, 데이터 접근 시 에러가 발생했을 때 그 에러를 복구하거나 플랜B로 처리하고 싶다면 어떻게 할까?

예를 들어, 새로운 데이터를 데이터베이스에 생성하고자 하는데 새로운 데이터의 기본키가 이미 존재한다면 어떻게 하면 좋을까?

 

프로젝트나 비즈니스마다 다르겠지만 이런 경우도 있을것이다.

새로운 값으로 또는 다른 값으로 대체하여 추가하자.


이런 경우를 알아보자.

우선, DB마다 에러가 발생하면 에러 코드를 반환하는데 그 에러 코드가 DB별로 상이하다. 일단 이 부분이 굉장히 큰 문제거리가 되지만 우선 H2 데이터베이스를 사용한다고 했을 때, 중복 PK 에러 코드는 '23505'이다.

 

그 경우에는 새로운 PK값으로 재시도를 할 것이다. 그러기 위해선 SQLException은 체크 예외이기 때문에 런타임 예외로 커스텀을 해야한다. 그렇지 않으면 의존 관계 문제가 발생하면서 동시에 SQLException 하위 에러 모두 이 에러로 반환될 수 있다.

 

SMALL

MyDuplicateKeyException

package com.example.jdbc.exception;

public class MyDuplicateKeyException extends RuntimeException {

    public MyDuplicateKeyException() {
    }

    public MyDuplicateKeyException(String message) {
        super(message);
    }

    public MyDuplicateKeyException(String message, Throwable cause) {
        super(message, cause);
    }

    public MyDuplicateKeyException(Throwable cause) {
        super(cause);
    }
}

 

이제 중복키 관련 에러가 발생하면 런타임 예외를 상속받은 MyDuplicateKeyException을 반환할 것이다.

아래 코드는 DB에 INSERT 쿼리를 날렸을 때 SQLException이 발생하는 에러를 catch에서 잡아준다. 이 때 이 에러의 에러 코드가 23505(H2의 중복키 에러코드)일 경우 위에서 새로 만든 MyDuplicateKeyException을 던진다.

@RequiredArgsConstructor
static class Repository {
    private final DataSource dataSource;

    public Member save(Member member) {
        String sql = "INSERT INTO member(member_id, money) values(?, ?)";
        Connection connection = null;
        PreparedStatement pstmt = null;

        try {
            connection = dataSource.getConnection();
            pstmt = connection.prepareStatement(sql);
            pstmt.setString(1, member.getMemberId());
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate();
            return member;
        } catch (SQLException e) {
            // 데이터 베이스의 오류 중 특정 예외는 복구하고 싶을 수 있는 경우가 있다. 그럴 때 이렇게 우리만의 에러를 만들어서 리턴해주고 받는 쪽은
            // 그 에러인지 확인해서 그 에러라면 복구 로직을 작성하면 된다.
            if (e.getErrorCode() == 23505) {
                throw new MyDuplicateKeyException(e);
            }
            throw new MyDbException(e);
        } finally {
            JdbcUtils.closeStatement(pstmt);
            JdbcUtils.closeConnection(connection);
        }
    }
}

 

그럼 이제 서비스는 이 리포지토리를 호출한다. 

@RequiredArgsConstructor
static class Service {
    private final Repository repository;

    public void create(String memberId) {
        try {
            repository.save(new Member(memberId, 0));
            log.info("saveId={}", memberId);
        } catch (MyDuplicateKeyException e) {
            log.info("키 중복, 복구 시도");

            String retryId = generateNewId(memberId);
            log.info("retryId = {}", retryId);

            repository.save(new Member(retryId, 0));
        } catch (MyDbException e) {
            log.info("데이터 접근 계층 예외", e);
            throw e;
        }
    }

    private String generateNewId(String memberId) {
        return memberId + new Random().nextInt(10000);
    }
}

 

서비스에서 리포지토리가 가진 create() 메소드를 호출할 때 catch에서 MyDuplicateKeyException을 받는 경우엔 새로운 키를 생성하는 메소드 generateNewId()를 호출한다. 해당 메소드로부터 돌려받는 키로 다시 새로운 멤버를 만드는 save() 메소드를 호출하여 재실행한다.

 

이제 테스트 코드로 실행해보자.

@Slf4j
public class ExTranslatorV1Test {

    Repository repository;
    Service service;

    @BeforeEach
    void init() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        repository = new Repository(dataSource);
        service = new Service(repository);
    }

    @Test
    void duplicateKeySave() {
        service.create("myId");
        service.create("myId"); // 같은 ID 저장 시도
    }
    
    ...
}

 

@SpringBootTest가 아니기 때문에 Test를 실행하기 전 DataSource를 직접 만들어 repository에게 전달해줘야 한다. 그 부분이 @BeforeEach. 그리고 @Test 어노테이션이 붙은 메소드를 실행해보자. 같은 아이디를 사용해서 create() 메소드를 두번 호출하기 때문에 두번째 생성 시 MyDuplicateKeyException이 발생한다. 그러나 해당 예외를 처리하면서 다른 ID로 재시도를 하기 때문에 myId를 가진 멤버 한명과 새로운 아이디를 가진 멤버 한명 이렇게 두 명의 멤버가 정상적으로 만들어진다.

 

결과

 

전체 코드

package com.example.jdbc.exception.translator;

import com.example.jdbc.connection.ConnectionConst;
import com.example.jdbc.domain.Member;
import com.example.jdbc.exception.MyDbException;
import com.example.jdbc.exception.MyDuplicateKeyException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.support.JdbcUtils;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Random;

import static com.example.jdbc.connection.ConnectionConst.*;

@Slf4j
public class ExTranslatorV1Test {

    Repository repository;
    Service service;

    @BeforeEach
    void init() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        repository = new Repository(dataSource);
        service = new Service(repository);
    }

    @Test
    void duplicateKeySave() {
        service.create("myId");
        service.create("myId"); // 같은 ID 저장 시도
    }

    @RequiredArgsConstructor
    static class Service {
        private final Repository repository;

        public void create(String memberId) {
            try {
                repository.save(new Member(memberId, 0));
                log.info("saveId={}", memberId);
            } catch (MyDuplicateKeyException e) {
                log.info("키 중복, 복구 시도");

                String retryId = generateNewId(memberId);
                log.info("retryId = {}", retryId);

                repository.save(new Member(retryId, 0));
            } catch (MyDbException e) {
                log.info("데이터 접근 계층 예외", e);
                throw e;
            }
        }

        public String generateNewId(String memberId) {
            return memberId + new Random().nextInt(10000);
        }
    }


    @RequiredArgsConstructor
    static class Repository {
        private final DataSource dataSource;

        public Member save(Member member) {
            String sql = "INSERT INTO member(member_id, money) values(?, ?)";
            Connection connection = null;
            PreparedStatement pstmt = null;

            try {
                connection = dataSource.getConnection();
                pstmt = connection.prepareStatement(sql);
                pstmt.setString(1, member.getMemberId());
                pstmt.setInt(2, member.getMoney());
                pstmt.executeUpdate();
                return member;
            } catch (SQLException e) {
                if (e.getErrorCode() == 23505) {
                    throw new MyDuplicateKeyException(e);
                }
                throw new MyDbException(e);
            } finally {
                JdbcUtils.closeStatement(pstmt);
                JdbcUtils.closeConnection(connection);
            }
        }
    }
}

 

 

그런데, 이렇게 막상 코드를 짜보니 이걸 일일이 다 할 수 있을까? 라는 의문이 생긴다. 그도 그럴것이 데이터베이스에서 반환되는 에러코드만 따져도 수십개 수백개는 될 것같은데 이게 데이터베이스 별로 다르다는 건 또 다른 문제가 된다. 이 문제를 스프링이 해결해준다.

 

스프링 데이터 접근 예외 계층

 

스프링이 만들어준 데이터 접근 예외 계층 구조도이다. 그림에서 볼 수 있듯 가장 상위 예외는 RuntimeException이다. 즉, 스프링이 제공하는 데이터 접근 예외들 모두가 언체크 예외라는 의미이다. 그 바로 하위에는 DataAccessException이 있다. 그리고 여기서 두 분류로 분개를 하는데 하나는 NonTransient 나머지 하나는 Transient이다. 

 

Transient는 일시적이라는 의미이다. 즉, 이 에러부터 하위 에러는 다시 시도했을 때 성공할 가능성이 있는 경우이다. 예를 들면 쿼리 타임아웃이나 락과 관련된 에러이다. 데이터베이스의 부하가 줄어들면 쿼리 타임아웃에 걸리지 않거나 락이 풀린 상태에서 접근했을 땐 문제 없이 성공할 수 있는 그런 경우들을 말한다.

 

NonTransient는 일시적이지 않다는 의미이다. 같은 SQL을 그대로 반복해서 실행하면 실패한다. SQL문법 오류인 경우 같은 쿼리를 백날 다시 날려도 똑같이 에러가 날 것이다. 데이터베이스 제약 조건에 위배되는 경우 언제나 위배가 될 것이다 제약 조건이 변경되기 전까지.

 

 

이 스프링이 제공하는 데이터 접근 예외를 사용해서 일일이 커스텀할 필요 없이 가져다가 사용해보자.

package com.example.jdbc.exception.translator;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator;

import javax.sql.DataSource;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;

import static com.example.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.*;

@Slf4j
public class SpringExceptionTranslatorTest {

    DataSource dataSource;

    @BeforeEach
    void init() {
        dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
    }

    @Test
    void springSqlException() {
        String sql = "bad sql grammar";

        try {
            Connection connection = dataSource.getConnection();
            PreparedStatement preparedStatement = connection.prepareStatement(sql);
            preparedStatement.executeQuery();
        } catch (SQLException e) {
            int errorCode = e.getErrorCode();
            assertThat(errorCode).isEqualTo(42122);

            SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
            DataAccessException resultEx = exTranslator.translate("selectQuery", sql, e);
            log.info("resultEx", resultEx);

            assert resultEx != null;
            assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
        }
    }
}

 

위 코드에서 주의깊게 볼 부분은 이 부분이다.

SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exTranslator.translate("selectQuery", sql, e);
log.info("resultEx", resultEx);

 

SQLException이 터지는 경우 catch에서 해당 에러를 다루는데, 그 에러의 종류를 스프링이 위에서 봤듯이 만들어 놨기 때문에 가져다가 사용하면 된다. 그 에러를 가져오는 방법은 SQLErrorCodeSQLExceptionTranslator를 사용하면 된다. 

 

새로운 SQLErrorCodeSQLExceptionTranslator객체를 만들어야 하는데 객체를 만들 때 DataSource 객체를 파라미터로 넘겨야한다. 그렇게 만들어진 SQLErrorCodeSQLExceptionTranslator객체를 이용해 실제 SQLException을 SQL문과 같이 translate()에 넘겨주면 스프링이 알아서 변환해준다. 그 객체의 타입은 DataAccessException이고 이 객체는 위 구조도에서 봤듯 가장 상위 에러 객체이다. 

 

그리고 실제 이 변환된 에러의 클래스는 BadSqlGrammarException이다. 그것을 확인하는 코드가 다음 한 줄이다.

assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);

 

이렇게 우리 대신 스프링이 알아서 다 만들어준다. 근데 그럼 스프링은 각기 다른 DB의 에러코드를 어떻게 알고 이런 에러를 반환해주는걸까? 스프링은 이미 다 정의를 해놨다. 그 정의 파일은 다음 파일이다. sql-error-codes.xml

 

이 파일을 열어보면 다음 사진처럼 생겼다.

 

이렇게 모든 코드가 Database별로 정의가 되어 있고, 해당 코드이면 어떤 에러를 뱉을지 모두 다 정의가 되어있다. 이렇게 우리 대신 정의된 스프링이 만들어준 것을 가져다가 사용하면 일일이 커스텀하여 에러를 만들 필요는 없어지고 그렇기에 실용적인 코드를 작성할 수 있다.

 

그러나, 물론 스프링이 만든 데이터 접근 예외를 가져다가 사용을 한다면 의존 관계에서 독립적이지 못하게 되는 서비스 코드가 만들어진다. 즉, 다음 패키지가 서비스 또는 리포지토리에 자리잡게 된다.

import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.BadSqlGrammarException;

 

그럼 이러한 의존 관계에 대한 독립성과 실용적인 코드작성 사이에 Trade-off가 생기는 것인데, 이는 프로젝트 별 어떤 선택을 하냐에 달려있다. 사실 모든 데이터베이스가 보내는 에러 코드를 다 커스텀한 에러로 변환하는 것은 거의 불가능에 가깝고 너무나 비효율적이기 때문에 이런 경우 trade-off를 가져가는 것이 방안이 될 수 있다. 순수함에 취해 효율과 실용성을 잃는다면 그것또한 다시 고려해 봐야 하지 않을까?

 

그럼에도 DataAccessException은 스프링이 제공하는 데이터 접근 예외라 어떤 특정 기술(JDBC, JPA,...)에 종속적이지 않고 기술을 변경하더라도 그대로 사용할 수 있다. 

 

728x90
반응형
LIST

'Spring + Database' 카테고리의 다른 글

[Renewal] JdbcTemplate  (8) 2024.12.06
[Renewal] 테스트 시 데이터베이스 연동  (0) 2024.12.05
[Renewal] 예외  (0) 2024.12.05
[Renewal] 스프링의 트랜잭션  (0) 2024.11.24
[Renewal] 트랜잭션, DB 락 이해  (0) 2024.11.22
728x90
반응형
SMALL

자바에서 예외는 크게 두 가지(체크 예외, 언체크 예외)로 나뉘어진다. 

SMALL

체크 예외

컴파일러가 체크하는 예외이다. 체크 예외는 잡아서 처리하거나 또는 밖으로 던지도록 선언해야한다. 그렇지 않으면 컴파일 오류가 발생한다.

언체크 예외

컴파일러가 체크하지 않는 예외이며, 런타임 예외라고도 불린다. 언체크 예외와 체크 예외의 차이가 있다면 예외를 던지는 throws를 선언하지 않고 생략할 수 있다. 이 경우에 자동으로 예외를 던진다. 

 

예외 계층

 

예외 계층 구조를 그림으로 보자.

 

예외 역시 객체이므로 최상위 부모인 Object가 예외 객체의 부모가 된다. 

Throwable은 예외의 최상위 객체이다. 그리고 이 Throwable 객체의 하위에는 ExceptionError가 있다.

 

Error: 메모리 부족이나 심각한 시스템 오류와 같이 애플리케이션에서 복구가 불가능한 시스템 예외이자 언체크 예외이다. 애플리케이션 개발자는 이 예외를 잡으려고 해서는 안된다. 당연하게도 잡는다고 코드적으로 해결되는 문제가 아니기 때문이다. 따라서 애플리케이션 로직에서는 Throwable 예외도 잡으면 안되는데, 그 이유는 ErrorThrowable의 하위 예외이기 때문이다. 이러한 이유로 애플리케이션 로직은 Exception부터 필요한 예외로 생각하고 잡으면 된다.

 

Exception체크 예외로, 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외이다. Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외지만 RuntimeException만은 아니다. RuntimeException은 언체크 예외이다. 

 

RuntimeException: 언체크 예외이며 런타임 예외라고도 자주 불린다. 컴파일러가 체크하지 않는 언체크 예외이고 이 자식 예외들은 모두 언체크 예외이다.

 

예외 기본 규칙

예외는 폭탄 돌리기와 같다. 1. 잡아서 처리하거나  2. 처리할 수 없으면 밖으로 던진다. 여기서 밖으로라는 말은 자신을 호출한 곳을 의미한다.

 

설명: 위 그림처럼 Repository에서 예외가 발생했고 그 예외를 Repository에서는 처리하지 못하여 자신을 호출한 Service로 예외를 던졌다. 예외를 받은 Service는 이 곳에서 Repository가 던질 수 있는 가능성이 있는 예외를 처리한 후 정상 흐름을 자신을 호출한 Controller에게 돌려준다.

 

예외를 잡거나 던질 때 지정한 예외뿐만 아니라 그 예외의 자식들도 함께 처리된다. 

그래서 Exceptioncatch로 잡으면 그 하위 예외들도 모두 잡을 수 있다. 또는 Exceptionthrows로 던지면 그 하위 예외들도 모두 던질 수 있다.

 

설명: 예외를 처리하지 못하면 호출한 곳으로 계속 예외를 던진다.

그래서? 예외를 처리하지 못하고 계속 던지면 어떻게 될까? 자바 main() 쓰레드의 경우 결국 어디서도 처리하지 못하고 예외가 최초 시작 지점인 main()까지 도달하면 예외 로그를 출력하면서 시스템이 종료된다. 그러나 웹 애플리케이션의 경우 시스템이 종료되는 현상은 일어나면 안되기 때문에 WAS까지 해당 예외가 올라오면 그 예외를 받아 처리하는데 주로 사용자에게 개발자가 지정한 오류 페이지를 보여준다. (그런 페이지가 없다면 WAS에서 기본으로 제공하는 에러 페이지나 에러 API를 던진다.)

 

 

체크 예외 예시 코드

체크 예외는 반드시 던지거나 처리하거나 둘 중 하나는 해야한다. 그렇지 않으면 컴파일러가 컴파일 오류를 뱉어내기 때문에. 이게 체크 예외의 기본 규칙이다.

package com.example.jdbc.exception.basic;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class CheckedTest {

    /**
     * Exception을 상속받은 예외는 체크 예외가 된다.
     * */
    static class MyCheckedException extends Exception {
        public MyCheckedException(String message) {
            super(message);
        }
    }

    static class Service {
        Repository repository = new Repository();

        /**
         * 체크 예외를 잡아서 처리하는 코드
         * */
        public void callCatch() {
            try {
                repository.call();
            } catch (MyCheckedException e) {
                log.info("예외 처리, message: {}", e.getMessage(), e);
                e.printStackTrace();
            }
        }

        /**
         * 체크 예외를 던지는 코드
         * @throws MyCheckedException
         * */
        public void callThrow() throws MyCheckedException {
            repository.call();
        }
    }

    static class Repository {
        public void call() throws MyCheckedException {
            throw new MyCheckedException("ex");
        }
    }

}

 

위 코드를 보면, MyCheckedException 이라는 체크 예외를 만들었다. Exception을 상속받으면 그 예외는 체크 예외가 된다. 그리고 Service에서 Repository를 사용하는데 Repositorycall() 메소드는 체크 예외(MyCheckedException)를 던진다. 이럴 때 이 call() 메소드를 호출한 서비스 쪽에서는 두가지 행위를 취할 수 있게 된다. 던지거나 잡거나. callCatch() 메소드는 catch에서 해당 에러를 잡는다. callThrow() 메소드는 해당 에러를 던진다. 이것이 체크 예외를 다루는 기본 방식이다. 그럼 이 서비스를 호출하는 쪽은 어떻게 될까?

 

package com.example.jdbc.exception.basic;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;


class CheckedTestTest {

    @Test
    void checked_catch() {
        CheckedTest.Service service = new CheckedTest.Service();
        service.callCatch();
    }

    @Test
    void checked_throw() {
        CheckedTest.Service service = new CheckedTest.Service();
        assertThatThrownBy(service::callThrow)
                .isInstanceOf(CheckedTest.MyCheckedException.class);
    }
}

 

서비스를 호출하는 쪽 역시 마찬가지로 체크 예외이기 때문에 던져진 예외는 잡거나 던져야한다. 여기서 checked_catch() 메소드는 서비스의 callCatch() 메소드를 호출하고 이는 메소드 내에서 예외를 잡았기 때문에 어떠한 행위도 할 필요가 없다. 그러나, checked_throw() 메소드는 callThrow()를 호출하기 때문에 던져진 예외를 받는다. 그렇기 때문에 그 던져진 예외를 검증하는 테스트 코드가 들어가 있다.

 

체크 예외의 장단점

  • 장점: 개발자가 실수로 예외를 누락하지 않도록 컴파일러를 통해 문제를 잡아주는 훌륭한 안전 장치가 된다.
  • 단점: 실제로는 개발자가 모든 체크 예외를 반드시 잡거나 던지도록 처리해야 하기 때문에, 너무 번거로운 일이 된다. 크게 신경쓰고 싶지 않은 예외까지 모두 챙겨야 한다.

 

언체크 예외 예시 코드

언체크 예외는 역시 체크 예외와 같이 잡거나 던지거나하면 되는데, 잡지도 던지지도 않아도 컴파일러는 이에 대해 오류를 뱉어내지 않는다.

그리고 던질 땐 throws를 생략해도 무방하다. 자동으로 던져준다.

package com.example.jdbc.exception.advance;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class UncheckedTest {

    /**
     * RuntimeException을 상속받은 예외는 언체크 예외가 된다.
     * */
    static class MyUncheckedException extends RuntimeException {
        public MyUncheckedException(String message) {
            super(message);
        }
    }

    /**
     * 언체크 예외는 예외를 잡거나 던지지 않아도 된다. 잡지 않으면 자동으로 밖으로 던진다.
     * */
    static class Service {
        private final Repository repository = new Repository();

        public void callCatch() {
            try {
                repository.call();
            } catch (MyUncheckedException e) {
                log.info("예외 처리 message = {}, ", e.getMessage(), e);
            }
        }

        public void callThrows() {
            repository.call();
        }
    }

    /**
     * 언체크 예외는 에러를 던질 때 throws가 생략 가능하다.
     * */
    static class Repository {
        public void call() {
            throw new MyUncheckedException("ex");
        }
    }
}

 

설명: MyUncheckedExceptionRuntimeException을 상속받는다. 이 RuntimeException을 상속받으면 언체크 예외가 된다. 그리고 RepositoryService를 만들고 Repositorycall() 메소드는 언체크 예외를 던진다. 이 call() 메소드를 호출하는 Service는 해당 예외를 잡을수도 던질수도 있으며 잡는 코드는 callCatch() 메소드이며 던지는 코드는 callThrows() 메소드이다. 마찬가지로 던질땐 throws를 생략할 수 있기 때문에 callThrows()에도 따로 throws를 작성하지 않아도 된다. 

 

이를 호출하는 테스트 코드를 작성해보자.

package com.example.jdbc.exception.advance;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

class UncheckedTestTest {
    @Test
    void unchecked_catch() {
        UncheckedTest.Service service = new UncheckedTest.Service();
        service.callCatch();
    }

    @Test
    void unchecked_throws() {
        UncheckedTest.Service service = new UncheckedTest.Service();


        assertThatThrownBy(service::callThrows)
                .isInstanceOf(UncheckedTest.MyUncheckedException.class);
    }
}

 

unchecked_catch() 메소드는 서비스가 가지는 callCatch() 메소드를 호출한다. 이 callCatch() 메소드는 내부에서 언체크 예외를 잡아서 정상 흐름을 반환하고 그대로 테스트는 통과한다. 그러나, unchecked_throws() 메소드는 서비스가 가지는 callThrows() 메소드를 호출한다. 이 callThrows() 메소드는 내부에서 언체크 예외를 잡지 않고 던지기 때문에 이를 호출한 unchecked_throws() 메소드 역시 잡거나 던지거나 둘 중 하나를 해야한다. 그래서 언체크예외가 던져지는 것을 확인하는 Assertions을 작성한다.

 

 

언체크 예외 장단점

  • 장점: 신경쓰고 싶지 않은 언체크 예외를 무시할 수 있다. 체크 예외의 경우 처리할 수 없는 예외를 밖으로 던지려면 항상 throws를 선언해야 하지만 언체크 예외는 이 부분을 생략할 수 있다. 
  • 단점: 언체크 예외는 개발자가 실수로 예외를 누락할 수 있다. 그리고 실제 서비스가 실행되는 시점에 언체크 예외가 터지면 애플리케이션에서 오류를 뱉어낸다. 런타임 시에 발생하는 에러, 다시 말해 사용자가 수행한 행위가 에러로직을 발생할 때 일어나는 에러가 가장 최악의 에러인데 이 최악의 에러를 발생시킬 수 있다.

 

체크 예외와 언체크 예외를 언제 사용할까?

기본적으로 언체크 예외를 사용하자.

 

이것이 대원칙이다. 대원칙은 기본으로는 언체크 예외를 사용하고, 비즈니스 로직 상 반드시 처리해야 하는 문제일 때만 체크 예외를 사용하자. 예를 들면 다음과 같다

  • 계좌 이체 실패 예외
  • 결제시 포인트 부족 예외
  • 로그인 ID, PW 불일치 예외

그렇다고 저 예시들도 반드시 체크 예외를 사용해야 하는게 아니라, 정말 정말 처리를 반드시 해줘야하는 경우에 체크 예외를 사용하면 된다는 것이다. 그것은 비즈니스 요구사항과 프로젝트 별로 달라지기 때문에 프로젝트마다 적절히 판단하는 것이 중요하다.

 

그런데 얼핏 말만 들어보면 체크 예외는 컴파일 시 오류도 잡아주고 발생할 수 있는 예외를 미리 알려주기까지 하는데 왜 체크 예외를 기본으로 사용하는게 아니고 언체크 예외일까?

 

체크 예외의 문제점

 

위 그림을 보자. 서비스에서 Repository, NetworkClient를 호출하는데 각각 체크 예외 (SQLException, ConnectException)을 던진다고 해보자. 그럼 두 곳에서 올라오는 체크 예외를 서비스가 처리할 수 있을까? 만약, DB가 터져서 SQLException을 서비스가 받으면 서비스가 그 에러를 처리할 수 있을까? 못한다. 아무것도 할 수 있는게 없다. 또 다른 예로 만약 네트워크가 일시적 문제가 생겨서 ConnectException을 서비스가 받으면 그 체크 예외를 처리할 수 있을까? 못한다. 아무것도 역시 할 수 있는게 없다. 이럴 땐 서비스 역시 저 두개의 에러가 체크 예외이기 때문에 던져야한다. 던지면 컨트롤러가 받는다. 컨트롤러라고 뭘 할 수 있을까? 못한다. 할 수 있는게 없다.

 

그럼 이 처리할 수 없는 체크 예외 때문에 컨트롤러가 100개면 100개 모두 throws 선언을 해야하는거고 서비스가 200개면 200개 모두 throws를 선언해야 하는 불필요한 공수가 들어간다. 또한 DB가 문제가 생겨서 아무것도 할 수 없는데 throws만 선언한다고 능사가 아니다. 

 

이런 네트워크 에러나 DB관련 문제는 보통 사용자에게 어떤 문제가 발생했는지 설명하기가 어렵다. 가령, 사용자에게 "DB가 터졌습니다. 😢" 이런 에러를 보여줄 수 있을까? 어떤 사이드 이펙트가 생길지 알고 저런 에러 메시지를 보낼까? 안된다. 그래서 이렇게 해결이 불가능한 공통 예외는 별도의 오류 로그를 남기고 개발자가 최대한 빠르게 인지할 수 있도록 하여야 하는 것이지 체크 예외라고 능사가 아니다.

 

체크 예외의 또다른 심각한 문제는 의존 관계 문제이다. 위처럼 복구가 불가능한 예외(SQLException, ConnectException)가 체크 예외가 된다면 서비스 입장에서는 잡거나 던져야하는데 처리할 수 없으니 던질 수 밖에 없다. 그럼 그 던져진 예외를 받는 컨트롤러역시 잡거나 던져야한다. 이 컨트롤러에서도 역시 처리할 수 없으니 던지게 되는데 이 때가 의존 관계 문제가 생기는 지점이다. 즉, 컨트롤러는 java.sql.SQLException을 의존하게 된다. 이게 왜 문제가 될까? 만약 향후에 JDBC 기술이 아닌 다른 기술로 변경한다면, 예를 들어 JDBC에서 JPA로 변경한다면 컨트롤러의 throws 코드를 모두 JPAException으로 변경해야 한다. 

 

어? 그럼 그냥 throws Exception으로 하면 되는거 아닌가요?

 

이 방법은 의존 관계에 상관없이 모든 체크 예외를 처리해줄 수 있기 때문에 위에서 말한 의존 관계 문제 자체는 해결해줄 수 있을지 모른다. 그러나, 이는 또 다른 심각한 문제를 야기한다. 이 Exception은 모든 체크 예외의 부모이기 때문에 정말 확실하게 체크하고 넘어가야 하는 체크 예외를 놓칠 수 있게 된다. 그래서 throws Exception은 절대 절대 사용하지 말자. 이건 매우 좋지 않은 안티 에러 패턴이다.

 

체크 예외를 다루는 예시 코드를 한번 보자.

체크 예외 예시 코드

package com.example.jdbc.exception.advance;

import java.net.ConnectException;
import java.sql.SQLException;

public class CheckedAppTest {

    static class Controller {
        Service service = new Service();

        public void request() throws SQLException, ConnectException {
            service.logic();
        }
    }

    static class Service {
        Repository repository = new Repository();
        NetworkClient networkClient = new NetworkClient();

        public void logic() throws SQLException, ConnectException {
            repository.call();
            networkClient.call();
        }
    }

    static class NetworkClient {
        public void call() throws ConnectException {
            throw new ConnectException("연결 실패");
        }
    }

    static class Repository {
        public void call() throws SQLException {
            throw new SQLException("ex");
        }
    }
}

 

ServiceRepositoryNetworkClient의 메소드를 모두 호출해야 한다. Repositorycall() 메소드가 있고 이 메소드는 SQLExceptionthrows로 선언했다. 즉, 체크 예외를 던진다는 의미이다. 그리고 NetworkClientcall() 메소드가 있고 이 메소드는 ConnectExceptionthrows로 선언했다. 마찬가지로, 체크 예외를 던진다는 의미이다. 

 

이 때, Service에서 logic() 메소드는 RepositoryNetworkClient가 던지는 SQLExceptionConnectException을 처리할 능력이 없다. 사실상 처리할 수 있는 뚜렷한 방법도 없다. 그렇기 때문에 서비스 역시 이 에러들을 던진다. 그럼 Controller는 서비스를 호출하고 서비스가 던진 체크 예외를 받는다. 이 때 컨트롤러 역시 해당 에러들을 처리할 수 있는 능력은 없다. 그렇기에 또 던진다. 여기서 서비스와 컨트롤러에 의존 관계 문제가 생긴다. 즉, 컨트롤러와 서비스 각각이 java.sql.SQLException 의존 관계가 생기는 것. 그리고 모든 메소드마다 throws 선언을 해야하는 번거로움은 덤이다.

 

이런 체크 예외에 대한 문제를 해결하기 위해 대원칙으로 언체크 예외를 기본으로 사용하자고 했다. 언체크 예외를 사용하는 코드를 예시로 보자.

언체크 예외 예시 코드

package com.example.jdbc.exception.advance;

import java.net.ConnectException;
import java.sql.SQLException;

public class UnCheckedAppTest {

    static class Controller {
        Service service = new Service();

        public void request() {
            service.logic();
        }
    }

    static class Service {
        Repository repository = new Repository();
        NetworkClient networkClient = new NetworkClient();

        public void logic() {
            repository.call();
            networkClient.call();
        }
    }

    static class NetworkClient {
        public void call() {
            throw new RuntimeConnectException("연결 실패");
        }
    }

    static class Repository {
        public void call() {
            try {
                runSQL();
            } catch (SQLException e) {
                throw new RuntimeSQLException(e);
            }
        }

        public void runSQL() throws SQLException {
            throw new SQLException("ex");
        }
    }

    static class RuntimeConnectException extends RuntimeException {
        public RuntimeConnectException(String message) {
            super(message);
        }
    }

    static class RuntimeSQLException extends RuntimeException {
        public RuntimeSQLException(String message) {
            super(message);
        }

        public RuntimeSQLException(Throwable cause) {
            super(cause);
        }
    }
}

 

설명: 이제 SQLExceptionConnectException을 언체크 예외로 변경하기 위해 RuntimeException을 상속받는 RuntimeSQLException, RuntimeConnectException 객체를 만들었다. 그리고 RepositoryrunSQL() 메소드에서 SQLException을 던지는데 이 runSQL() 메소드를 호출하는 call() 메소드에서 이 던져진 SQLExceptionRuntimeSQLException으로 치환하여 던진다. 이 때 RuntimeSQLException은 언체크 예외이기 때문에 throws를 선언하지 않아도 아무런 문제가 없다. (물론 선언하여 IDE의 도움을 받아 좀 더 명확하게 어떤 에러를 던질 수 있는지 확인하면 좋은 방법이 될 수 있다.) NetworkClient 객체가 가지고 있는 call() 메소드는 이제 RuntimeConnectException을 던진다. 이 또한 언체크 예외이기 때문에 throws를 선언하지 않아도 된다. Service에서는 이 두 객체의 각각의 call() 메소드를 호출한다. 저 둘이 던질 수 있는 언체크 예외가 있어도 따로 다루지 않아도 된다. 그렇기에 의존 관계 문제도 사라진다. 그렇기에 throws를 일일이 선언할 필요도 없다. 이 서비스를 호출하는 Controller 역시 마찬가지다. 

 

이제 런타임 에러로 변환하여 DB, 네트워크 에러처럼 발생하면 어떤 방법으로도 처리를 할 수 없는 경우에 적절히 에러 로그를 남긴 후 그냥 에러를 사용자에게 적절하게 알리고 개발자가 그 에러를 빠르게 대응하는 방식으로 운영할 수 있다. 그렇게 해야만 하고 그렇게밖에 할 수 없다. 그렇지만 의존 관계 문제라던가 발생 가능한 모든 예외를 처리해야 하는 체크 예외의 번거로움은 사라졌다.

 

그리고 이 언체크 예외를 공통으로 처리하는 부분이 따로 있으면 된다.

Database에서 생긴 에러라던가, Network 에러는 어차피 에러를 잡아도 복구할 수 없다. 이런것들을 체크 예외로 만들면 호출하는 쪽에서만 고생할 뿐이다. 체크 예외는 잡거나 던지지 않으면 컴파일 에러가 나니까 무조건 건드려줘야 하는 부분이라 그렇다. 그러니까 언체크 예외로 발생 가능성이 있는 에러를 정의하고 사용하는 쪽에서 에러가 나도 처리할 필요 없다. 처리할 수도 없을 뿐더러. 

 

그리고 그 에러를 공통으로 처리하는 부분이 있으면 되는것이다. 가령, 사용자에겐 "서버에 문제가 발생했다"는 안내 문구를 보여주고 개발자는 발생한 로그를 통해 빠르게 에러를 수정하는 방향으로 구현하는게 가장 좋은 방법이다.

 

그러나, 위 코드로 변경할 때 (체크 예외에서 언체크 예외로 예외 변경) 주의할 점이 있다.

주의할 점

기존 예외를 반드시 스택 트레이스에 추가를 해줘야한다. 그렇지 않으면 실제로 에러가 난 원인을 알 수가 없다.

 

아래 코드를 보자.

package com.example.jdbc.exception.advance;

import java.net.ConnectException;
import java.sql.SQLException;

public class UnCheckedAppTest {

    static class Controller {
        Service service = new Service();

        public void request() {
            try {
                service.logic();
            } catch (Exception e) {
                log.error("error: ", e);
            }
        }
    }

    static class Service {
        Repository repository = new Repository();
        NetworkClient networkClient = new NetworkClient();

        public void logic() {
            repository.call();
            networkClient.call();
        }
    }

    static class NetworkClient {
        public void call() {
            throw new RuntimeConnectException("연결 실패");
        }
    }

    static class Repository {
        public void call() {
            try {
                runSQL();
            } catch (SQLException e) {
                throw new RuntimeSQLException();
            }
        }

        public void runSQL() throws SQLException {
            throw new SQLException("ex");
        }
    }

    static class RuntimeConnectException extends RuntimeException {
        public RuntimeConnectException(String message) {
            super(message);
        }
    }

    static class RuntimeSQLException extends RuntimeException {
        public RuntimeSQLException() {
        }

        public RuntimeSQLException(String message) {
            super(message);
        }

        public RuntimeSQLException(Throwable cause) {
            super(cause);
        }
    }
}

 

여기서 중요한 부분은 바로 이부분이다.

public void call() {
    try {
        runSQL();
    } catch (SQLException e) {
        throw new RuntimeSQLException();
    }
}

 

이 부분이 체크 예외를 언체크 예외로 변경하는 부분이다. 실제 체크 예외인 SQLExceptionRuntimeSQLException으로 언체크 예외로 변경한다. 변경 자체에는 아무런 문제가 없지만 변경하면서 실제 에러에 대한 내용을 언체크 예외에 포함하지 않아버렸다. 이 때 에러가 발생하면 실제 SQLException 예외가 어떤식으로 왜 발생했는지 알 턱이 없다. 실행해보자.

 

실행 코드

package com.example.jdbc.exception.advance;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;


class UnCheckedAppTestTest {

    @Test
    void unchecked() {
        UnCheckedAppTest.Controller controller = new UnCheckedAppTest.Controller();
        
        controller.request();
        //Assertions.assertThatThrownBy(controller::request).isInstanceOf(RuntimeException.class);
    }

}

 

결과

 

결과에는 RuntimeSQLException에 대한 내용만 나오지 실제 에러의 원인인 SQLException에 대한 내용이 스택 트레이스에 빠져있다. 

이러면 이 에러가 발생해도 뭐때문에 에러가 났는지 알 수 없다. 그러니 반드시 반드시 스택 트레이스에 원래 에러를 포함시켜야한다.

 

포함하는 방법은 다음처럼 생성자에 에러를 추가해주면 된다.

public void call() {
    try {
        runSQL();
    } catch (SQLException e) {
        throw new RuntimeSQLException(e);
    }
}

 

RuntimeSQLException의 생성자는 여러가지가 있지만 Throwable 객체를 받는 생성자가 있다. 그 생성자를 이용하면 스택 트레이스에 생성자로부터 받은 에러를 포함시킬 수 있다. 

static class RuntimeSQLException extends RuntimeException {
    public RuntimeSQLException() {
    }

    public RuntimeSQLException(String message) {
        super(message);
    }

    public RuntimeSQLException(Throwable cause) {
        super(cause);
    }
}

 

 

스택 트레이스에 실제 원인을 가지는 에러를 포함시킨 후 다시 실행해보자.

스택 트레이스에 에러를 포함한 결과

 

스택 트레이스에 SQLException의 에러 내용이 포함되어 있음을 확인할 수 있다. 이렇게 반드시 체크 예외를 언체크 예외로 변경 시에는 기존 에러를 스택 트레이스에 넣어줘야 한다.

참고로, 에러를 찍을때 e.printStackTrace() 이런거 쓰지말자. 이게 System.out으로 찍히는 것인데 그렇게 찍으면 안되고 로그로 찍어야한다. 운영상에 System.out은 절대 사용하지 않아야 한다.

 

728x90
반응형
LIST
728x90
반응형
SMALL

자바로 개발을 할때 이 지루한 코드들을 보거나 작성해 본 경험이 있으신가요?

package cwchoiit;

public class Member {
    private String name;
    private int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}
  • Getter, Setter의 작업은 굉장히 개발자로 하여금 지루한 코드 작성 요소라고 볼 수 있습니다.
  • 물론, 요즘에는 IDE의 도움을 받아 굉장히 편리하게 작성을 해주지만 그럼에도 불구하고 코드가 쓸데없이 방대해지고 길어지는 건 막을 수가 없죠.

 

여기서 우리의 위대한 선배 개발자님들은 이 과정에 지루함을 느꼈습니다. 이 과정이 자동화되면 좋겠다고 말이죠. 그렇게 탄생한 정말 개인적으로 엄청 위대한 라이브러리라고 생각되는 이 `Lombok`. 많이들 사용하시나요? 저는 필수로 사용중입니다. 

 

그래서 다음과 같이 위 코드를 대체할 수가 있죠.

package cwchoiit;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class Member {

    private String name;
    private int age;
}
  • 위 코드와 비교해서 얼마나 깔끔한가요? 쓸데없이 길게 늘어진 Getter, Setter가 없으니 훨씬 보기도 편하고, 여기에 의미있는 메서드나 도메인 주도 개발을 포커스로 개발하시는 분들한테는 더할 나위 없이 좋겠죠.

 

그런데 궁금하지 않으셨나요? 도대체 어떻게 이렇게만 하면 자동으로 위에 코드처럼 Getter, Setter가 만들어지고 사용할 수가 있는지? 전 이 부분이 너무 너무 궁금했습니다. 그래서 공부하고 찾아보게 됐습니다. Lombok의 원리!

 

Lombok의 핵심 키워드는! → '애노테이션 프로세싱'과 'AST 조작'

 

애노테이션 프로세싱

1. 애노테이션 프로세서(Annotation Processor)란?

  • Java의 애노테이션 프로세서는 컴파일 단계에서 애노테이션을 분석하고, 이를 기반으로 추가적인 코드를 생성하거나 컴파일러에 특정 작업을 지시하는 기능을 제공합니다.
  • 이는 Java Compiler API의 일부로, 애노테이션 기반의 코드 생성 및 검증을 지원하는 도구입니다. 
  • 애노테이션 프로세서는 `javax.annotation.processing` 패키지에 정의된 인터페이스와 클래스들을 통해 구현됩니다. 
  • 일반적으로 컴파일 타임에 실행되며, 런타임에 영향을 미치지 않습니다.

2. 애노테이션 프로세서(Annotation Processor)의 주요 역할

  • 애노테이션 처리: 소스 코드에 선언된 애노테이션을 감지하고, 이를 기반으로 처리 작업 수행
  • 소스 코드 생성: @Getter와 같은 애노테이션을 통해 Getter 메서드 자동 생성.
  • 애노테이션 검증: 애노테이션 사용이 올바른지 확인하고, 잘못된 경우 컴파일러 오류를 출력해준다. 예를 들어, 특정 필드에만 적용 가능한 애노테이션을 다른 위치에 사용했는지 검증할 수 있습니다.
  • 리소스 파일 생성: 컴파일 시점에 특정 리소스 파일 생성 (예: `.properties` 파일)

3. 애노테이션 프로세서(Annotation Processor)의 동작 원리

3-1. 컴파일러와의 통합

 

애노테이션 프로세서는 Java 컴파일러(Javac)의 플러그인 형태로 작동합니다.

컴파일러가 코드를 컴파일하면서 애노테이션 프로세서를 호출하여 필요한 작업을 수행합니다.

 

3-2. Annotation Processing API

 

애노테이션 프로세서를 작성하려면 `javax.annotation.processing` 패키지의 API를 사용해야 합니다.

핵심 클래스 및 인터페이스는 다음과 같습니다.

  • Processor 인터페이스
    • 모든 애노테이션 프로세서는 이 인터페이스를 구현해야 합니다. 대부분은 AbstractProcessor 클래스를 확장하여 구현합니다.
  • ProcessingEnvironment 인터페이스
    • 컴파일러와 애노테이션 프로세서 간의 통신을 위한 환경을 제공합니다.
    • 소스 코드 생성 도구(Filer)와 메시지 출력 도구(Messager)를 포함합니다.
  • RoundEnvironment 인터페이스
    • 컴파일러가 처리 중인 애노테이션 및 소스 코드 정보를 제공합니다.

 

 

바이트코드 조작

Lombok은 바이트코드를 직접 변경하는 것은 아니고, 컴파일러의 AST(Abstract Syntax Tree)를 조작하여 결과적으로 수정된 바이트코드를 생성합니다. 이게 무슨 말일까요?

 

Java에서 AST(Abstract Syntax Tree)는 컴파일러가 소스 코드를 분석할 때 생성하는 구조화된 트리 형태의 데이터 구조를 말합니다.

이 트리는 소스 코드의 문법 요소(클래스, 메서드, 변수 등)를 계층적으로 표현하며, 컴파일러는 이 AST를 기반으로 바이트코드(.class 파일)를 생성합니다. 

 

그러니까, Lombok이 "AST를 조작한다"는 말은 컴파일러가 생성한 이 구문 트리에 접근하여 수정하거나 요소를 추가한다는 의미겠죠. 예를 들면, @Getter 애노테이션이 붙은 필드에 대해 Getter 메서드 노드를 트리에 삽입하겠고 그렇게 조작된 AST를 컴파일러가 바이트코드(.class 파일)로 변환하면서 실제로 동작하는 코드가 만들어집니다.

 

말로만 얘기해서는 AST에 대해 이해하기가 조금 난해합니다. 다음 코드를 보시죠!

 

Java 코드

public class Member {
    private String name;

    public String getName() {
        return name;
    }
}

AST

- Class: Member
  - Field: name (Type: String, Modifier: private)
  - Method: getName (Type: String, Modifier: public)
    - Return: name
  • 이게 바로 AST입니다. 컴파일러는 이 AST를 사용하여 소스 코드의 문법 오류를 검증하고 바이트코드를 생성해 냅니다.

Lombok의 동작 방식: AST 조작

그러니까 엄밀히 따져서 Lombok은 바이트코드를 조작하는 것이 아니라, AST를 조작한다고 보면 되겠죠. Lombok은 애노테이션 프로세서로 동작하며, 컴파일러가 AST를 생성하는 과정에서 해당 트리를 수정합니다. 이를 통해 추가적인 코드를 자동으로 삽입하거나 수정합니다. 

 

그러니까, Lombok@Getter와 같은 애노테이션을 감지하고, 이를 기반으로 AST에서 필드에 대응하는 Getter 메서드 노드를 삽입합니다. 최종적으로 조작된 AST는 컴파일러가 다시 바이트코드로 변환하구요.

 

예를 들어볼까요? 다음 소스 코드를 보시죠.

package cwchoiit;

public class Member {
    @Getter
    private String name;
}
  • 컴파일러가 AST를 생성합니다. Member 클래스와 name 필드를 포함하는 트리 구조를 생성하겠죠. 바로 아래와 같이요.
- Class: Member
  - Field: name (Type: String, Modifier: private)
  • 그 후 Lombok 애노테이션 프로세서가 AST를 조작합니다. 
  • Lombok@Getter 애노테이션이 붙은 필드를 감지합니다. 그리고 그 필드에 대한 GettergetName() 메서드의 노드를 AST에 삽입합니다. 그래서 아래와 같이 조작된 AST가 만들어집니다!
- Class: Member
  - Field: name (Type: String, Modifier: private)
  - Method: getName (Type: String, Modifier: public)
    - Return: name
  • 이제 컴파일러가 다시 이 AST를 통해 바이트코드를 생성해 냅니다. 
  • 그래서, 결과 바이트코드는 getName() 메서드를 포함하게 되죠.

 

정리를 하자면

Lombok의 동작원리를 살펴보았습니다. 핵심 키워드는 [애노테이션 프로세싱]과 [AST 조작]이라고 할 수 있는데요. 이 과정을 통해 지루한 반복 코드를 깔끔하게 애노테이션으로 해결할 수가 있게 됐습니다. 물론, Lombok에 관련된 논란 거리도 있습니다만, 전 Lombok이 좋네요. 

 

 

나도 한번 만들어볼까?

이해를 해봤으니, 직접 다뤄볼까요? 간단하게라도 직접 만들어보기까지 한다면 조금 더 이 과정이 이해될 것 같습니다.

`chyonibok` 이라는 이름의 프로젝트를 하나 만들고 다음과 같이 코드를 작성했습니다.

package cwchoiit;

public class Member {
    @AutoGetter
    private String name;
}
  • 지금 저 @AutoGetter 애노테이션은 아무런 동작도 하지 않습니다. 심지어 컴파일 오류가 납니다. 이렇게 만든 Member라는 클래스가 @AutoGetter 애노테이션이 달린 필드를 보고 해당 필드의 Getter 메서드를 자동으로 만들어주는 그 작업을 한번 해보겠습니다.

 

그러기 위해 프로젝트를 하나 더 만들 생각입니다. 라이브러리 역할을 하는. 그래서 새로 만든 프로젝트의 이름을 `chyoniboklib`라고 하고 새로 만들었습니다. 그리고 다음과 같이 애노테이션을 하나 생성했습니다.

package cwchoiit;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Retention(RetentionPolicy.SOURCE)
public @interface AutoGetter {
}
  • RetentionPolicySOURCE일까요? 이 애노테이션이 런타임이나 바이트코드에 필요할까요? 아닙니다. 컴파일 시에 해당 애노테이션을 찾아 후처리를 하고 AST 조작을 하면 끝나기 때문에 런타임이나 바이트코드에 필요하지가 않습니다. 따라서 CLASS, RUNTIME이 아닌 SOURCE로 지정했습니다.

 

애노테이션 프로세서 구현

자, 이제 애노테이션 프로세서를 구현해야 합니다. 아까 위에서도 얘기했지만, Processor를 직접 구현해도 된다만, 이미 여러 기능이 있는 AbstractProcessor를 구현하는게 일반적입니다. 따라서 저도 그렇게 구현해보도록 하겠습니다.

package cwchoiit;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.element.TypeElement;
import java.util.Set;

public class AutoGetterProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        return false;
    }
}
  • 반드시 구현해야 할 메서드는 `process` 딱 하나입니다. 
  • 그 다음 두가지 애노테이션을 붙여주겠습니다. 아래와 같이요.
package cwchoiit;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.TypeElement;
import java.util.Set;

@SupportedAnnotationTypes("cwchoiit.AutoGetter")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class AutoGetterProcessor extends AbstractProcessor {...}
  • 이 애노테이션 프로세서가 지원하는 애노테이션이 어떤 애노테이션인지를 알려주는 @SupportedAnnotationTypes 입니다. 당연히 위에서 만든 @AutoGetter입니다.
  • 그 다음은 지원하는 버전입니다. 최소 해당 버전 이상은 되어야 한다는 이야기고 여기서는 11버전을 작성했습니다. 참고로 이 애노테이션을 사용 안하면 AbstractProcessor가 가지고 있는 getSupportedSourceVersion()을 사용합니다.

 

자 그럼, 이제 애노테이션 프로세서를 직접 만들어 보겠습니다. 우선 구현해야 하는 메서드가 `process()`라고 했죠? 이건 뭐하는 앨까요?

이 메서드는 만약 `true`를 리턴하면, 여기서 이 애노테이션을 처리를 다 했다고 판단하고 다음 애노테이션 프로세서에게 넘기지 않습니다. 그러니까 만약, @AutoGetter를 처리하는 애노테이션 프로세서가 딱 이 하나라면 `true`를 리턴하면 되겠죠? 반면에, 또 다른 애노테이션 프로세서가 있고 그 프로세서 역시 @AutoGetter를 처리하는 또다른 애노테이션 프로세서라면 `false`를 리턴해서 두 프로세서 모두 통과하도록 해줘야 합니다. 그래서 저는 `true`를 리턴하도록 하겠습니다. 어차피 이거 하나만 만들거니까요.

package cwchoiit;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.TypeElement;
import javax.tools.Diagnostic;
import java.util.Set;

@SupportedAnnotationTypes("cwchoiit.AutoGetter")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class AutoGetterProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(AutoGetter.class);
        for (Element element : elements) {
            if (element.getKind() != ElementKind.FIELD) {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@AutoGetter 애노테이션은 필드에만 사용할 수 있습니다. 현재 사용 위치:" + element);
            } else {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "@AutoGetter 처리 대상 필드: " + element.getSimpleName());
            }
        }

        return true;
    }
}
  • 우선, 처음으로 할 일은 이 애노테이션의 검증입니다. 위에서 애노테이션 프로세서가 하는 역할 중에 애노테이션의 검증도 있다고 했죠? 적절한 곳에 애노테이션이 위치했는지 파악을 해야 합니다. 따라서 먼저 ElementKind.FIELD가 아닌 경우 컴파일 오류를 뱉어내도록 합니다. 
  • 일단 이 상태로만 마치고 애노테이션 프로세서를 등록해볼까요? 하나씩 하나씩 나아가면 좋으니까요.

애노테이션 프로세서는 어떻게 등록할까요? 원래는 이렇게 해야합니다.

  • main 하위에 `resources` 폴더를 만들고, 해당 폴더 하위에 META-INF/services 폴더를 만들어서 그 안에 `javax.annotation.processing.Processor`라는 파일을 만들어야 합니다.
  • 그리고 이 파일 안에 지금 제가 만든 애노테이션 프로세서의 풀 패키지명을 작성해주면 됩니다. 다음과 같이요.
cwchoiit.AutoGetterProcessor

 

그런 다음에 이 상태에서 빌드를 해볼까요? 다음 명령어를 실행해봅니다.

mvn clean install

 

잘 될까요? 안 됩니다. 다음과 같이 에러를 마주하게 됩니다.

이 에러가 발생하는 이유는, 지금 이 Maven으로 빌드를 하는 과정중에 즉, 소스를 컴파일 하는 시점에 이 프로세서가 동작을 하려고 합니다. 왜냐하면, 제가 프로세서로 등록을 했으니까요. 그런데 지금 최초 빌드이죠? 그럼 이 애노테이션 프로세서가 등록이 안 된 상태겠죠? 그래서 없다고 하는겁니다. 그래서 이 경우에는 어떻게 해야하냐면, 먼저 저 resources에 등록한 애노테이션을 잠깐 주석 처리하고 빌드를 합니다. 그러면 빌드가 끝나면 애노테이션 프로세서가 만들어지겠죠? 그리고 다시 `mvn install`을 하는겁니다.

 

그래서 우선, 애노테이션 프로세서를 등록한 것을 주석 처리 후 빌드를 먼저 합니다. 그럼 다음과 같이 정상적으로 빌드가 끝납니다.

 

그리고 컴파일된 파일들을 보시면, 다음과 같이 `AutoGetterProcessor`가 만들어졌죠?

이 상태에서 `mvn install`을 하면 됩니다. 그런데, 굉장히 불편하죠? 그래서 이런 불편함을 해결해주기 위한 라이브러리가 하나 있습니다. 바로 구글에서 만든 `auto-service`인데요. 한번 사용해보겠습니다. 다음과 같이 의존성을 추가해 줍니다.

<dependency>
  <groupId>com.google.auto.service</groupId>
  <artifactId>auto-service</artifactId>
  <version>1.1.1</version>
</dependency>

 

그리고, 아까 만든 애노테이션 프로세서에 가서, 이런 애노테이션 `@AutoService(Processor.class)` 을 추가해줍니다.

@AutoService(Processor.class)
@SupportedAnnotationTypes("cwchoiit.AutoGetter")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class AutoGetterProcessor extends AbstractProcessor {...}
  • 이 녀석을 추가하면, 아까 만든 `META-INF/services/javax.annotation.processing.Processor` 이 파일을 컴파일 하고 자동으로 만들어 주고, 그 파일 안에 이 애노테이션 프로세서를 등록해줍니다.

잘 되는지 확인하기 위해, 다시 `mvn clean install`을 해보면 정상적으로 빌드가 됩니다. 근데 눈으로 봐야 믿을 수 있잖아요 우리들은? 그래서 만들어진 `.jar`파일을 `.zip` 파일로 바꾼 다음에 안에 파일을 까볼까요? 

까보면, 이렇게 잘 만들어진 것을 확인할 수 있네요!

 

 

그럼 실제로 이 애노테이션 프로세서가 잘 동작하는지 확인해보겠습니다. 방금 다음 명령어를 입력했죠.

mvn clean install

이렇게 `install`을 하면 로컬 레포지토리에 해당 `.jar` 파일이 추가가 되잖아요? 

 

그 말은 다른 프로젝트에서 이 `.jar`파일을 다운받아 사용할 수 있다는 말입니다. 한번 아까 최초에 만든 `chyonibok`으로 다시 돌아가볼까요? 그 전에 pom.xml 파일에서 이 부분만 복사해서 돌아가죠. 내려받아야 하니까요.

 

그리고, 다시 돌아간 프로젝트에서 저 라이브러리를 추가해봅니다.

 

추가하면, 다음과 같이 외부 라이브러리에 잘 추가가 된 모습을 볼 수 있어요!

 

 

이제 아까 컴파일 오류가 났었던 @AutoGetter를 사용한 Member 클래스로 가볼까요? 이젠 컴파일 오류가 나지 않습니다!

그리고 이 상태에서 빌드를 해보시면 아무런 문제가 없을거에요. 왜냐하면 지금 애노테이션이 필드에 잘 붙어있으니까요. 그러나, 이 애노테이션을 클래스 레벨에 붙이면 이제 컴파일 오류를 보실 수 있습니다.

 

다음과 같이 클래스 레벨에 붙여주세요.

빌드를 하니 이런 예쁜 에러가 발생하네요! 😆

 

 

 

이제, 애노테이션 프로세서의 동작은 확인을 해봤습니다. 여기서 끝내면 안되죠?! 이제 실제로 Getter 메서드를 만들어 볼거예요!

Javapoet

이제 메서드를 동적으로 만들어야 합니다. 메서드나 클래스를 동적으로 만들어낼 때 자주 사용되는 라이브러리인 `javapoet`을 사용해볼게요.

package cwchoiit;

import com.google.auto.service.AutoService;
import com.squareup.javapoet.*;

import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.tools.Diagnostic;
import java.io.IOException;
import java.util.Set;

@AutoService(Processor.class)
@SupportedAnnotationTypes("cwchoiit.AutoGetter")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class AutoGetterProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(AutoGetter.class);
        for (Element element : elements) {
            if (element.getKind() != ElementKind.FIELD) {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@AutoGetter 애노테이션은 필드에만 사용할 수 있습니다. 현재 사용 위치:" + element);
            } else {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "@AutoGetter 처리 대상 필드: " + element.getSimpleName());
            }

            VariableElement variableElement = (VariableElement) element; // 필드로 캐스팅
            TypeElement enclosingClass = (TypeElement) variableElement.getEnclosingElement(); // 부모 클래스
            String fieldName = variableElement.getSimpleName().toString(); // 필드 이름 
            TypeName fieldType = TypeName.get(variableElement.asType()); // 필드 타입

            String className = enclosingClass.getSimpleName().toString(); // 기존 클래스 명 
            String packageName = processingEnv.getElementUtils() // 기존 패키지 명
                    .getPackageOf(enclosingClass)
                    .getQualifiedName()
                    .toString();

            // Getter 메서드 생성
            MethodSpec getterMethod = MethodSpec.methodBuilder("get" + capitalize(fieldName))
                    .addModifiers(Modifier.PUBLIC)
                    .returns(fieldType) // 반환 타입 설정
                    .addStatement("return this.$L", fieldName) // 메서드 본문
                    .build();

            // 새 클래스 생성
            TypeSpec newClass = TypeSpec.classBuilder(className + "AutoGenerated")
                    .addModifiers(Modifier.PUBLIC)
                    .addField(fieldType, fieldName, Modifier.PRIVATE) // 기존 필드 추가
                    .addMethod(getterMethod) // Getter 메서드 추가
                    .build();

            Filer filer = processingEnv.getFiler();

            try {
                JavaFile.builder(packageName, newClass)
                        .build()
                        .writeTo(filer);
            } catch (IOException e) {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "Fatal: " + e.getMessage());
            }
        }

        return true;
    }

    private String capitalize(String name) {
        return Character.toUpperCase(name.charAt(0)) + name.substring(1);
    }
}
  • 우선, 해당 필드의 Getter 메서드를 만들어야겠죠?
MethodSpec getterMethod = MethodSpec.methodBuilder("get" + capitalize(fieldName))
                                    .addModifiers(Modifier.PUBLIC)
                                    .returns(fieldType) // 반환 타입 설정
                                    .addStatement("return this.$L", fieldName) // 메서드 본문
                                    .build();
  • 현재 필드의 필드 이름을 가지고 Getter 메서드를 만듭니다. MethodSpecjavapoet에서 제공해주는 메서드를 동적으로 만들어주는 녀석입니다. 이렇게 Getter 메서드를 동적으로 만들 수가 있습니다.

그런데, 사실 Lombok은 AST를 조작한다고 했잖아요? 이 AST를 조작해서 기존 클래스에 메서드를 추가하는 방식을 Lombok은 컴파일러 내부 API를 사용합니다. 그런데 말이죠. 컴파일러 내부 API는 공개 API가 아닙니다. 쉽게 말해 저희같은 일반인들은 가져다가 사용하는게 거의 불가능에 가깝죠. 과거 JDK8 이전에는 `tools.jar` 파일에 컴파일러 내부 API가 포함되어 있었다고 합니다. 그런데 JDK9 이상부터는 모듈로 전환이 되고, 해당 모듈은 기본적으로 공개되지 않습니다. 그래서, 기존 클래스에 추가하는 방식 말고 아예 새로운 클래스를 만들고 그 클래스에 Getter를 추가한 클래스를 만들어 볼게요. 결국 이 과정도 애노테이션 프로세서를 이용하는 것이니까요.

// 새 클래스 생성
TypeSpec newClass = TypeSpec.classBuilder(className + "AutoGenerated")
        .addModifiers(Modifier.PUBLIC)
        .addField(fieldType, fieldName, Modifier.PRIVATE) // 기존 필드 추가
        .addMethod(getterMethod) // Getter 메서드 추가
        .build();
  • `javapoet`으로 클래스를 만드려면 TypeSpec을 사용하면 됩니다. 기존 클래스명에 `AutoGenerated`라는 이름을 추가해볼게요.
  • 그리고 여기에 addMethod()로 아까 위에서 만든 메서드를 추가해주면 됩니다.
  • 여기까지만하면 실제로 만든건 아니고 메모리에 클래스와 메서드를 만들어 낸것까지 한거예요. 우리는 메모리에만 둥둥 떠있는게 아니라 실제 클래스로 만들어야겠죠?
Filer filer = processingEnv.getFiler();

try {
    JavaFile.builder(packageName, newClass)
            .build()
            .writeTo(filer);
} catch (IOException e) {
    processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "Fatal: " + e.getMessage());
}
  • 그래서, JavaFile로 해당 클래스가 있던 패키지 위치에 새로 만든 클래스를 만들어 줍니다. 

 

이제, 다 끝났습니다. 이 소스를 다시 `install`해서 `chyonibok` 프로젝트에서 사용해볼게요. 다시 설치하면 사용했던 프로젝트에서도 다시 로드를 해줘야해요. 그래서 `chyonibok` 프로젝트에서 다음과 같이 리로드를 해주세요.

 

리로드가 잘 됐으면, 다시 다음 명령어를 실행해볼까요? 다시 `chyonibok` 프로젝트를 컴파일해서 애노테이션 프로세서가 동작해야겠죠?

mvn clean compile

 

컴파일한 후, 만들어진 `target` 폴더에 어떤 클래스가 있는지 보면 놀랄겁니다!

이렇게 `MemberAutoGenerated`라는 클래스가 생겨버렸어요! `chyoniboklib` 프로젝트로 만든 애노테이션 프로세서가 잘 동작해서 이 라이브러리를 가져다가 사용하는 `chyonibok` 프로젝트에서 컴파일 시 해당 애노테이션 프로세서가 동작한거예요! 이 코드 한번 볼까요? Getter가 아주 이쁘게 만들어졌습니다.

 

가져다가 사용하는 것도 당연히 되겠죠? 한번 해볼까요? 

  • 문제없이 잘 가져다가 사용할 수가 있습니다. (참고로, 이게 사용이 안된다면 IDE에서 Enable annotation processing을 체크해줘야 합니다)

 

정리

Lombok의 동작 원리와 실제로 비슷하게나마 Lombok의 구현을 해봤습니다. 사실 Lombok은 새로운 클래스를 만들지는 않습니다. 기존 클래스에 메서드를 붙여버립니다. 그 방식을 AST 조작을 통해서 해내는 것이구요. 이 포스팅에서는 AST 조작 대신 새로운 클래스를 만들어내고 그 클래스에 Getter 메서드를 만들어봤습니다. 저는 처음에 Lombok의 동작 원리를 이해해보니 이렇게까지 깊은 내용이구나.. 싶으면서도 또 다른 세계가 펼쳐진 것 같아서 재밌었습니다. 

 

그럼, AST 조작을 하는건 거의 불가능에 가깝잖아요? 자바 컴파일러 내부 API를 사용하니까요. 그럼 바이트코드 조작을 해서 기존 클래스에 Getter 메서드를 추가하는건 어떨까요?! 재밌을 것 같지 않나요?

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

람다 vs 익명 클래스  (0) 2025.03.30
람다가 필요한 이유  (0) 2025.03.28
[Java 8] CompletableFuture  (0) 2024.11.30
[Java 8] Optional  (0) 2024.11.27
[Java 8] Stream API  (0) 2024.11.27
728x90
반응형
SMALL

이번 포스팅에서는 자바8에서 새로 도입된 CompletableFuture에 대해 알아보자. 이름에 Future가 들어가니까 Executors 프레임워크를 사용할 때 배워봤던 그 Future와 연관이 있나? 생각이 든다. 맞다.

 

CompletableFuture의 탄생 배경

자바에서 비동기 프로그래밍을 하려고 하면, Future를 사용해서도 어느정도는 가능했다만, 불편한 점들이 있다.

뭐가 불편했지?를 고민하기 전에 자바에서 비동기 프로그래밍이라는 건 어떤건지 먼저 감을 잡기 위해 아래 코드를 보자.

package executors;

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService executorService = Executors.newSingleThreadExecutor();

        Future<String> future = executorService.submit(new Task());

        // 이곳에서 future 를 기다리지 않고 계속 작업이 가능

        String futureResult = future.get();// 블로킹 메서드
        
        // 이곳에서 future 의 결과를 가지고 작업을 할 수 있음
        System.out.println(futureResult);

        executorService.shutdown();
        executorService.close();
    }

    static class Task implements Callable<String> {

        @Override
        public String call() throws Exception {
            System.out.println("Hello Callable");
            return "Im Callable";
        }
    }
}
  • 지금 코드를 보면, Executors 프레임워크를 사용해서 쓰레드 하나짜리 풀을 만들고 Callable을 수행한다.
  • 비동기 프로그래밍이란 건 Taskcall() 메서드를 실행하는 것을 기다리지 않고 내가 하고자하는 작업을 진행하는 걸 말한다.
  • 근데 여기서 불편한 점이 있다. future.get()을 호출하기 전에는 기다리지 않고 계속 무언가 작업이 가능하지만 get()을 호출하고 나서는 결국 저 Future의 작업이 끝날때까지 기다려야 하는 블로킹이 걸린다.
  • 그리고 나서 그 결과를 받아오고 나서 그 결과를 가지고 작업을 할 수 있다.

그러니까, Future를 이용해서 비동기 프로그래밍을 하려면 결국 get()을 호출하기 전에 최대한 열심히 무언가 작업을 해야하고 get()을 호출하고 나서 그 Future의 결과를 통해 어떤 작업을 수행할 수 있는 것이다. 

 

그런데 이제 이런 불편함이 생긴것이다.

→ 블로킹 메서드(get())을 호출하기 전에는 작업이 끝났을 때 콜백함수를 실행할 수 없다. 콜백함수를 미리 지정해 놓을 수 있다면 굉장히 편리할 것 같다.

 

CompletableFuture 사용

비동기 작업의 리턴값이 없는 경우: runAsync()

위와 같은 불편함을 해소하고자 이 CompletableFuture가 등장했다. 이 코드는 어떻게 사용하는지 한번 보자.

package executors;

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) {
        CompletableFuture.runAsync(() -> System.out.println("Run" + Thread.currentThread().getName()));

        System.out.println("Hi " + Thread.currentThread().getName());
    }
}
  • 어떤 작업을 비동기적으로 실행만 하면 될때, CompletableFuturerunAsync()를 사용할 수 있다.
  • 이렇게 작성하면, CompletableFuture의 작업과는 아무런 영향없이 그 이후의 코드를 실행할 수 있다.

실행 결과

 

비동기 작업의 리턴값이 있는 경우: supplyAsync()

이번에는 위와 다르게 비동기 작업의 리턴값이 있는 경우에는 어떻게 하면 될까?

package executors;

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        });

        System.out.println("Hi " + Thread.currentThread().getName());
        System.out.println("Future return: " + future.get());
    }
}
  • 어떤 작업을 비동기적으로 수행하고 그 수행의 리턴값이 있는 경우에는 이렇게 supplyAsync()를 사용하면 된다.

실행 결과

 

 

그런데 지금까지는, Future를 사용하는 것과 별반 다른게 없다. 그래서 이제 Future와 어떤것이 확연히 다른지를 살펴보자.

CompletableFuturecallback

자, 만약 내가 어떤 작업을 비동기적으로 수행하고 그 결과를 통해 무언가를 또 하고 싶을때 어떻게 하면 될까? Future를 사용했을 땐, get()을 호출하고 결과를 받을때까지 블로킹 상태로 대기하다가 결과가 나오면 그때 무언갈 할 수 있었다.

package executors;

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        }).thenApply(s -> {
            System.out.println(s + " " + Thread.currentThread().getName());
            return s.toUpperCase();
        });

        System.out.println("Hi " + Thread.currentThread().getName());
        System.out.println("Future return: " + future.get());
    }
}
  • 코드를 보면, thenApply()를 호출한다. 이게 바로 어떤 비동기 작업의 결과를 통해 무언가를 실행하는 콜백 함수이다.
  • 그래서, 결과로 받은 문자열을 대문자로 전부 변경하고 그 값을 반환한다.
  • 실제로 future.get()을 호출해보면 결과는 다음과 같다.

 

물론, 콜백 함수가 어떤 반환값이 필요하지 않은 경우가 있을수도 있다.

package executors;

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        }).thenAccept(System.out::println);

        System.out.println("Hi " + Thread.currentThread().getName());
        System.out.println("Future return: " + future.get());
    }
}
  • 그럴때는 이렇게 thenAccept()를 호출하면 된다.
  • 그리고 이게 다 이전 포스팅을 배운 이유인게 thenAccept()는 인자로 무엇을 받냐면 Consumer를 받는다. Consumer는? 무언갈 받아서 소비만 하고 따로 리턴하는 게 없는 함수형 인터페이스다.

  • 반대로, 아까 콜백 함수를 사용하는데 반환값도 있었던 thenApply는 무엇을 받을까?

  • 이렇듯 Function을 받는다. 함수형 인터페이스 Function은 어떤값(T)를 받아 어떤값(U)로 반환한다.

 

그래서 이렇게 CompletableFuture를 사용하면, 비동기 프로그래밍을 훨씬 쉽고 편리하게 할 수가 있다.

그런데, 궁금한게 있다. Future를 사용했을 때는 Executors 프레임워크로 스레드 풀을 만들고 그 쓰레드 풀에서 스레드를 꺼내와서 사용했는데 여기서는 어떻게 된게 쓰레드 풀도 따로 안 만들고 어떻게 쓰레드가 생기고 하는 걸까?

 

실행해서 현재 쓰레드의 이름을 찍어보면 이런식으로 나온다.

ForkJoinPool.commonPool-worker-1

ForkJoinPool? 이 녀석은 자바7에서 추가된 병렬 작업을 처리하기 위한 효율적인 스레드 풀이다. 그리고 CompletableFuture는 기본적으로 ForkJoinPool.commonPool()을 사용한다. 그런데 이 풀은 기본적으로 CPU 코어 수에 비례하여 스레드 수를 제한한다. 근데 사실 그렇게 되면 CPU 코어 수가 아무리 많아봐야 20개가 안되는데 보통의 멀티쓰레드를 사용하는 애플리케이션은 쓰레드 1000개도 만들고 한다. 따라서 생각보다 이 ForkJoinPool이 비효율적 일수가 있는데 이럴때를 대비해 명시적으로 Executors에서 사용하는 스레드풀을 지정할 수가 있다. 아래 코드를 보자.

package executors;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService executorService = Executors.newSingleThreadExecutor();

        CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        }, executorService);

        System.out.println(hello.get());
    }
}
  • supplyAsync()에는 두번째 파라미터로 Executor를 넘길 수가 있다. 그래서 내가 만든 ExecutorService를 넘겨주면 이 스레드 풀을 사용한다. 아 물론, supplyAsync()뿐 아니라 runAsync()도 이렇게 사용할 수 있다. 

 

CompletableFuture의 사용 2 - 조합

thenCompose()

이번에는 조금 더 깊게 들어가서, Future만을 사용했을 때 또 어떤 점이 불편했냐면, A라는 Future 하나와, B라는 Future 하나가 있을 때 이 두개를 이어서 하려면 A를 get()하고, B를 get()해서 이 두개의 결과를 가지고 이후 코드를 작성해야 했다.

 

근데, 이제 CompletableFuture를 사용하면, 어떻게 편리하게 사용할 수 있을까?

package executors;

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        });

        CompletableFuture<String> future = hello.thenCompose(Main::getWorld);
        System.out.println("future.get() = " + future.get());
    }

    private static CompletableFuture<String> getWorld(String word) {
        return CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return word + " World";
        });
    }
}
  • CompleteableFuture에는 thenCompose()라는 게 있다. 이 녀석을 사용하면 어떤 Future의 결과를 받아서 새로운 Future를 이어갈 수 있다.
  • thenCompose()는 파라미터로 Function 함수형 인터페이스를 받는다. 즉, 어떤 값을 받아 어떤 값으로 변환해준다는 의미가 된다. 그리고 반환타입은 CompletableFuture<T>이다. 새로운 Future를 반환한다는 의미이다.
  • 그래서, Future의 결과값을 전달해주고 새로운 Future를 만들어낸다.

  • 그래서 thenCompose()를 사용하면 CompletableFuture를 이어서 실행할 수 있다.

thenCombine()

그런데 위의 케이스의 경우, 두 Future가 서로 연관관계가 있어서 어떤게 먼저 실행하고 그 다음걸 실행할 때 유용하게 사용할 수 있다. 코드도 보면 "Hello World"를 찍기 위해 "Hello"를 반환하는 Future를 먼저 실행하고 그 다음 "World"를 반환하는 Future를 실행한 것처럼 Future끼리 연관관계가 있을 때 Future끼리 이어 실행할 수 있게 하는게 thenCompose()였다면, 아무런 연관관계가 없지만 동시에 실행시키고 싶을 때도 있을 것이다. 

package executors;

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        });

        CompletableFuture<String> world = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "World";
        });

        CompletableFuture<String> future = hello.thenCombine(world, (h, w) -> h + " " + w);
        System.out.println("future.get() = " + future.get());
    }
}
  • 이럴때 사용하는게 thenCombine()이다. 아무 연관관계는 없지만 동시에 실행시키고 그 두 Future의 결과값으로 새로운 것을 만들어낼 때 유용하다. 그래서 지금 hello, world 라는 두 CompletableFuture가 있을 때 얘네가 누가 먼저 실행되어야 하고 그런건 아니지만 이 두개의 결과를 가지고 무언가를 만들어낼때 사용하기 딱 좋은게 thenCombine()이다.
  • thenCombine()은 첫번째 인자로, CompletionStage를 받는다. 이건 CompletableFuture가 구현한 인터페이스라서 CompletableFuture가 들어갈 수 있다. 그리고 두번째 인자로 BiFunction을 받는다. 정말 딱 들어맞는 함수형 인터페이스 아닌가? 

실행 결과

 

 

allOf()

이번에는 CompletableFuture가 2개 이상일때, 그 모든 CompletableFuture를 한번에 다 처리하고 어떤 작업을 진행할 수 있는 방법을 소개한다. 

package executors;

import java.util.Arrays;
import java.util.List;
import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        });

        CompletableFuture<String> world = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "World";
        });

        CompletableFuture[] futures = new CompletableFuture[]{hello, world};
        CompletableFuture<List<Object>> results = CompletableFuture.allOf(futures)
                .thenApply(v -> Arrays.stream(futures).map(CompletableFuture::join).toList());

        results.get().forEach(System.out::println);
    }
}
  • allOf()CompletableFuture의 배열을 받는다. 그래서 넘겨받은 모든 CompletableFuture의 모든 작업이 끝나면, thenApply()가 호출되는데 여기서 각 Futureget()을 호출하면 되지만 get()은 체크 예외를 던지기 때문에 처리하기가 좀 난감해진다. 그렇기에 join()을 호출하면 동일하게 작업의 결과를 받아오지만 체크예외가 아닌 언체크예외를 던지기 때문에 예외처리를 위한 별도의 동작이 필요없어진다.
  • 물론, join() 대신 get()을 호출하고 예외처리를 직접 해주어도 상관은 없다. 어떤 것을 사용하든 각각의 Future의 결과값을 리스트로 변환하고 해당 리스트를 순회하면서 출력하는 코드이고 결과는 다음과 같다.

실행 결과

 

anyOf()

이번에는 모든 Future가 끝난 후 실행되는 것 말고 어떤 Future라도 제일 빨리 끝난게 생기면 무언가를 처리할 수 있는 anyOf()에 대해 알아보자. 

package executors;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        });

        CompletableFuture<String> world = CompletableFuture.supplyAsync(() -> {
            System.out.println("Run " + Thread.currentThread().getName());
            return "World";
        });

        CompletableFuture.anyOf(hello, world).thenAccept(System.out::println);
    }
}
  • 이렇게 anyOf()를 사용해서 여러 Future를 받고, thenAccept()를 호출해서 둘 중 먼저 실행이 끝난것을 받아 시스템 콘솔에 출력한다. 이때는 어떤게 먼저 실행될지에대한 보장은 없다. 그래서 실행할때마다 실행 결과가 달라진다.

실행 결과

 

예외 상황

이번에는 CompletableFuture를 실행하다가 예외가 발생한 경우 어떻게 다뤄야 하는지 알아보자.

package executors;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        boolean isError = true;

        CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> {
            if (isError) {
                throw new IllegalStateException("Oh shit Error!");
            }
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        }).exceptionally(ex -> {
            System.out.println("Exception " + ex.getMessage());
            return "Error!";
        });

        System.out.println(hello.get());
    }
}
  • CompletableFuture로 어떤 작업을 처리하다보면 당연하게도 예외는 발생할 수 있다. 그럴때 예외가 터지면 exceptionally()를 사용해서 해당 예외를 받고 어떤 처리를 할 수 있다.
  • 그래서 이 코드를 실행해보면 다음과 같다.

실행 결과

 

그런데 이제, 이렇게 exceptionally()를 사용해서도 처리할 수 있지만, 이건 완전 예외를 위한거라면 handle()이라는 녀석도 있다.

package executors;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        boolean isError = true;

        CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> {
            if (isError) {
                throw new IllegalStateException("Oh shit Error!");
            }
            System.out.println("Run " + Thread.currentThread().getName());
            return "Hello";
        }).handle((result, error) -> {
            if (error != null) {
                System.out.println(error.getMessage());
                return "Error!";
            }
            return "Hello!";
        });

        System.out.println(hello.get());
    }
}
  • handle()은 정상케이스와 에러케이스 두 개를 모두 다룰 수 있다. 그리고 handle()의 파라미터를 보면 BiFunction이다. 두개를 받아 하나로 반환하는 함수형 인터페이스. 확실히 배우면 배울수록 함수형 인터페이스를 잘 배워놨다는 생각이 든다.

실행 결과

 

 

정리를 하자면

자바에서 멀티쓰레드를 다루는 방법의 아주 대표적인 프레임워크는 Executors 프레임워크다. 이 녀석을 사용해서 쓰레드 풀도 만들고 Callable을 실행하고 Future를 받아 처리한다. 아주 좋지만, Future의 단점 중 하나는 비동기 프로그래밍을 하기가 꽤나 까다롭다는 것이다. 그래서 자바8부터 CompletableFuture가 등장하고 이 녀석을 사용하면 작업이 다 끝난 후 콜백함수를 정의하여 매우 편하게 비동기 프로그래밍을 할 수 있게 됐다. 그에 대한 내용을 살펴보았다.

728x90
반응형
LIST
728x90
반응형
SMALL

자바8 이후에 또 아주 자주 사용되고 중요한 Optional에 대해 알아보자!

 

NullPointerException

자바 프로그래밍을 하다가 왜 이 NullPointerException이 종종 발생할까? 왜긴 왜야? null 체크를 깜빡 했으니까. 다음 코드를 보자.

 

Shop

public class Shop {
    private String name;
    private User host;
    private boolean isOpen;

    public Shop() {
    }

    public Shop(String name, boolean isOpen) {
        this.name = name;
        this.isOpen = isOpen;
    }

    public Shop(String name, User host, boolean isOpen) {
        this.name = name;
        this.host = host;
        this.isOpen = isOpen;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public User getHost() {
        return host;
    }

    public void setHost(User host) {
        this.host = host;
    }

    public boolean isOpen() {
        return isOpen;
    }

    public void setOpen(boolean open) {
        isOpen = open;
    }

    @Override
    public String toString() {
        return "Shop{" +
                "name='" + name + '\'' +
                ", isOpen=" + isOpen +
                '}';
    }
}

 

User

public class User {
    private String name;
    private int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "User{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}
  • Shop 이라는 클래스에는 세가지 필드가 있다. `name`, `host`, `isOpen`.
  • 그리고 `host`는 타입이 레퍼런스 타입(User)이기 때문에 이 필드에 무언갈 채우지 않았다면 그 값은 null이다.

OptionalMain

public class OptionalMain {
    public static void main(String[] args) {
        Shop shop = new Shop();
        shop.setName("Shop A");
        shop.setOpen(true);

        System.out.println(shop.getHost().getName());
    }
}
  • 이 코드를 실행하면 어떻게 될까? 이 코드를 실행하면 NullPointerException이 발생한다.
  • 왜냐? getHost()null인데, `null.xxx`를 하는 순간 해당 에러가 터지는 것이다.

 

그럼 이런 NullPointerException을 방지하려면 어떻게 코드를 짜야하냐? 자바8 이전에는 이렇게 했다.

public class OptionalMain {
    public static void main(String[] args) {
        Shop shop = new Shop("A", true);

        if (shop.getHost() != null) {
            System.out.println(shop.getHost().getName());
        }
    }
}
  • 해당 값이 null인지, null이 아닌지 체크를 먼저 하고 그 이후에 뭔가를 진행하는 코드를 작성했다.
  • 이때 문제는? 이 코드를 작성하는 건 '사람'이다. 사람은? null 체크를 까먹을 확률이 매우 크다. 

또 다른 방법으로는 이런 방법도 있겠다.

public User getHost() {
    if (host != null) {
        return host;
    }
    throw new IllegalStateException("this field null");
}
  • 이건 더 큰 문제가 있다. 
  • 우선 이것도 역시 사람이 작성하는 것이라 null 체크를 안 할 가능성이 있다.
  • 위 코드처럼 null 체크를 했다고 치자. null인 경우, IllegalStateException을 던진다. 예외를 던지는 것은 생각보다 비싼값을 치뤄야 한다. 왜냐? 스택 트레이스를 찍어야 하니까.
  • 그리고 예외를 던지면, 사용하는 클라이언트 코드는 이번엔 null 에서는 해방될지언정, 예외 처리를 해줘야 한다.

 

Optional의 등장

자바8 이후로 이 Optional이 등장하면서, Optional을 리턴할 수 있게 됐다.

Optional은 뭔데? → 오직 한 개의 값이 들어있을수도 안 들어 있을수도 있는 컨테이너.

 

다음 코드를 보자.

public Optional<User> getHost() {
    return Optional.ofNullable(host);
}
  • 이번엔 getHost()Optional<User>를 리턴한다.
  • 그리고 실제 반환값으로는 Optional.ofNullable(host); 이다. 이름 그대로 null일수도 있다는 뜻이다.

그럼 이 코드를 사용하는 클라이언트 쪽은 어떻게 하냐? 이렇게 한다.

import java.util.Optional;

public class OptionalMain {
    public static void main(String[] args) {
        Shop shop = new Shop();
        shop.setName("Shop A");
        shop.setOpen(true);

        Optional<User> host = shop.getHost();
        host.ifPresent(System.out::println);
    }
}
  • 이건 단지 예시 코드일 뿐이다. 보통은 메서드 체인 형태로 사용하겠지만 그냥 설명을 위해 이렇게 작성했다.
  • 클라이언트 코드는 이 타입이 Optional이라는 것을 확인하는 순간부터 무엇을 생각하게 되냐면 null을 생각하게 된다. 즉, 명시적으로 "이 값은 빈 값일수도 있어! 그러니까 체크해야해!" 이 말을 해주는 단어가 된다.
  • 그래서 사용자는, 이 값이 있다면 어떤 처리를 하고, 없다면 어떤 처리를 할지 분기할 수가 있다.
shop.getHost().orElseThrow(RuntimeException::new);
shop.getHost().orElse(new User("hello", 20));
shop.getHost().ifPresent(System.out::println);
shop.getHost().ifPresentOrElse(System.out::println, () -> { /*null 인 경우*/ });
  • 이렇게 말이다. 이 값이 없는 경우에 대한 처리를 사용자로부터 강제하게 된다.
  • 그러다 보니 조금이라도 null safety한 코드가 작성될 수 있을 것이다.

그러니까, 명백하게 이 Optional을 제대로만 사용한다면 NullPointerException 으로부터 꽤나 자유로워 질 수 있을것만 같다.

Optional 주의점

그런데 Optional을 사용할 때 주의점이 있다. 일단, 다음 코드를 보자.

1. null일 수 있는 값을 Optional.of(...)로 사용하지 말 것

public Optional<User> getHost() {
    return Optional.of(host);
}
  • 이 코드 매우 위험하다.
  • Optional.of(...)는 전달하는 인자가 null이면 안된다.
  • of()를 사용하면 그 값이 null이 아닌 상태여야 한다. 만약, null이라면 이 자체로 NullPointerException이 발생한다.

반드시 null일 수도 있는 값은 Optional.ofNullable(...)을 사용하자.

public Optional<User> getHost() {
    return Optional.ofNullable(host);
}

 

2. 리턴값으로만 사용하기를 권장한다.

이게 무슨말일까? 다음 코드를 보자.

public Optional<User> getHost() {
    return Optional.ofNullable(host);
}
  • 이게 바로 리턴값으로 사용한 Optional이다.
  • 이렇게만 사용하라는 이야기다.

 

메서드 매개변수 타입, 맵의 키 타입, 인스턴스 필드 타입으로 쓰는 경우가 있는데 그런 경우를 최대한 최대한 최대한 지양해라!

아래에서 하나씩 그 이유를 알아보자.

 

2-1. 메서드 매개변수 타입을 Optional로 사용하지 말 것

public String isHostNameEqualsShopName(Optional<User> host) {
        ...
}
  • 지금 이 코드가 메서드의 매개변수로 Optional을 사용한 것이다.
  • 이거 왜 사용하면 안되냐? 다음 코드를 보자.
public String isHostNameEqualsShopName(Optional<User> host) {
    host.ifPresent(u -> {
        if (u.getName().equalsIgnoreCase(this.name)) {
            System.out.println("is equals!");
        }
    });
}
  • 지금 Optional로 받은 파라미터 덕분에 ifPresent()를 사용하는데, 이거 굉장히 위험한 코드다. 왜냐?
import java.util.Optional;

public class OptionalMain {
    public static void main(String[] args) {
        Shop shop = new Shop();
        shop.setName("Shop A");
        shop.setOpen(true);

        shop.isHostNameEqualsShopName(null);
    }
}
  • 이렇게 파라미터에 null을 넘기는 순간? host.ifPresent(...)에서 NullPointerException이 발생한다.
  • 그러니까 메서드 매개변수로 Optional 사용하는거 하지말자!

 

2-2. 맵의 키로 Optional을 사용하지 말 것

맵의 키로 Optional을 사용하는 건 정말 뭐랄까..? 맵이라는 자료 구조의 컨벤션을 망가뜨리는 행위이다.

Map을 통해 어떤 키값이 있는지 찾고 있으면 있는거고 없으면 명확히 없는거지, 있을수도 있고 없을수도 있다? ...음.. 하지말자!

사용하는 사람 입장에서 얜 뭘까.. 싶은 코드다 정말 이건.

 

 

2-3. 인스턴스 필드 타입으로 Optional을 사용하지 말 것

import java.util.Optional;

public class Shop {
    private String name;
    private Optional<User> host;
    private boolean isOpen;
}
  • 이런 경우를 말한다. 필드에 Optional을 적용하는 것.
  • 이것도 굉장히 나쁜 코드이다. 

`host`라는 필드는 원래도 null이 될 수도 그렇지 않을 수도 있다. 그렇기 때문에 getHost()와 같은 메서드를 이렇게 작성하는 것이다.

public Optional<User> getHost() {
    return Optional.ofNullable(host);
}

 

그런데, 왜 필드 자체에 이 값이 있을 수도 있고 없을 수도 있다고 더 더 모호하게 상황을 만드는가? 하지 말자!

 

 

3. Primitive 타입의 Optional은 따로 존재한다.

Optional.of(10);
  • 이렇게 Optionalprimitive 타입을 받을 순 있다. 근데, 곰곰히 생각해보자. primitive 타입에 null이란게 존재하는가? 아니다. 그런건 없다. 근데 primitive 타입을 Optional로 만든다? 이건 뭔가 매우 어색하기도 하면서 이걸 처리하기 위해 Boxing을 한다. 즉, 이 반환값은 이렇다.
Optional<Integer> i = Optional.of(10);
  • 그렇다. 타입이 Optional<Integer>로 변경된다. Primitive → Wrapper 클래스가 된다는 말이다. 이 과정에서 역시 리소스도 낭비되고 좋지 않다.

 

그래서, 이런게 있다.

OptionalInt optionalInt = OptionalInt.of(10);
  • OptionalInt가 있으면, OptionalDoubleOptionalLong도 있다.

 

4. Optional을 반환 타입으로 사용할 때 null을 리턴하지 마라.

진짜 정말 안 좋은 코드이다. 바로 보자.

public Optional<User> getHost() {
    return null;
}
  • 반환 타입은 Optional<User>이다. 리턴 타입으로 사용하고 있으니 위에서 말한 주의사항엔 걸리지 않는다.
  • 그런데 이 메서드의 리턴값이 null이면 어떤 문제가 발생하냐면, 
import java.util.Optional;

public class OptionalMain {
    public static void main(String[] args) {
        Shop shop = new Shop();

        Optional<User> host = shop.getHost();
        host.ifPresent(System.out::println);
    }
}
  • 이걸 사용하는 클라이언트 코드는, 이 메서드를 호출했을 때 반환값이 Optional이기 때문에 "어 Optional이네, 있는지 없는지 체크해야겠다."라고 생각하고 .ifPresent(...)를 호출하는 순간 NullPointerException이다.

그래서 이러면 안되고, 다음과 같이 해야 한다.

public Optional<User> getHost() {
    return Optional.empty();
}
  • Optional.empty()를 사용하면 된다.

 

5. Collection, Map, Optional 등 이미 이 자체로 빈 값이 될 수 있다고 표현하는 것들을 Optional로 또 감싸지 마라.

위에서 Map의 키를 Optional로 감싸지 마라. 라는 내용과 유사한데, 이미 이 자체로 빈 값인지 아닌지 판단할 수 있는 컨테이너들을 왜 Optional로 감싸나? 그럴 이유가 없는데. Mapget()을 통해 값이 있으면 가져오고 값이 없으면 null이다. 여기서 이미 판단을 할 수 있다. Collection, Optional도 마찬가지다. 이미 이 자체로도 빈 값인지 아닌지를 판단할수가 있는데 왜 Optional로 또 감싸는건가? 이러면 안된다.

 

 

 

정리

Optional을 제대로만 사용한다면, NullPointerException을 마주하는 경우를 굉장히 획기적으로 줄일수도 있고, 코드 자체의 가시성도 더 높일 수 있다. 개인적으로 좋아하는 방식이기도 하다. 그런데 이 Optional을 사용하는데 있어서 클라이언트 입장에서 매우 짜증나는 경우가 몇가지 있는데 그 경우를 여기에 정리했으니 이렇게 작성하는 것을 피하자! 

728x90
반응형
LIST
728x90
반응형
SMALL

Java 8 이후 가장 관심을 많이 받은 건 아마도 Stream API가 아닐까 싶을 정도로 굉장히 이제는 중요하고 모르면 절대 안되는 이 녀석에 대해 공부해보자.

 

Stream

  • 데이터를 담고 있는 저장소(컬렉션)가 아니다.
  • 스트림이 처리하는 데이터 소스를 변경하는 게 아니다.
  • 스트림으로 처리하는 데이터는 오직 한번만 가능하다.
  • 중개 오퍼레이션은 근본적으로 Lazy하다.
  • 손쉽게 병렬 처리를 할 수 있다.

이게 다 무슨말일까? 하나씩 차근 차근 알아가보자. 

 

데이터를 담고 있는 저장소가 아니다.

이건 말 그대로 스트림으로 처리하는 데이터는 컬렉션이 아니라는 말이다.

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        Stream<String> stream = friends.stream();
    }
}
  • 위 코드를 보면, `friends`라는 Listfriends.stream()의 반환값은 명확히 Stream으로 다르다. 
  • 쉽게 말해 스트림은 저장소 역할을 하는게 아니라, 특정 자료구조에 대해 어떤 처리를 할 수 있는 일회성 공간이라고 생각해야 한다.

 

스트림이 처리하는 데이터 소스를 변경하는 게 아니다.

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        List<String> upper = friends.stream()
                .map(String::toUpperCase)
                .toList();

        System.out.println(friends);
        System.out.println(upper);
    }
}
  • 위 코드를 보면, `friends`를 스트림으로 각 요소별로 대문자로 변경하여 새로운 리스트를 만들어냈다.
  • 그럼 이건 기존의 `friends`도 변경하는 걸까? 아니다! 기존의 데이터 소스를 변경하는 게 아니다.
  • 다음 실행 결과를 보면, 기존의 데이터 소스는 그대로이고 스트림으로 처리한 데이터를 새로운 리스트를 반환하는 것이다.

실행 결과

 

스트림으로 처리한 데이터는 오직 한번만 가능하다.

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        Stream<String> friendsStream = friends.stream();

        List<String> upperFriends = friendsStream.map(String::toUpperCase).toList();
        System.out.println(upperFriends);

        List<String> lowerFriends = friendsStream.map(String::toLowerCase).toList();
        System.out.println(lowerFriends);
    }
}
  • 위 코드를 보면, `friends`라는 리스트를 스트림으로 변환한 `friendsStream`이 있다. 
  • 이 녀석으로 각 요소에 대해 toUpperCase()를 처리한 새로운 리스트를 만들었다.
  • 그 이후에 다시 이 `friendsStream`을 이용할 수 있을까? 그렇지 않다. 위 코드처럼 다시 `friendsStream`을 통해 각 요소에 대해 toLowerCase()를 처리한 새로운 리스트를 만드려고 한다면 다음과 같은 에러를 마주하게 된다.

 

 

중개 오퍼레이션은 근본적으로 Lazy하다.

이게 조금 중요한 말인데, 보통은 스트림을 사용할 때 다음과 같이 사용하는 사례를 많이 마주할 것이다.

customFieldManager.getCustomFieldObjects().stream()
        .filter(customField -> customField.getCustomFieldType().getName().equals("Date Picker"))
        .map(SimpleCustomFieldDTO::new)
        .collect(Collectors.toList());
  • 위 코드는 예시 코드이다. 
  • 스트림으로 변환한 후, filter()를 거치고, map()을 거쳐서, 리스트로 최종적으로 반환한다.
  • 이렇듯, 스트림은 중개 오퍼레이션과 종료 오퍼레이션이 나뉘어져 있다.
  • 여기서 중개 오퍼레이션은 filter(), map()이고, 종료 오퍼레이션은 collect()이다.
  • 쉽게 생각하면, 중개 오퍼레이션은 반환값이 그대로 Stream이다. 반대로 종료 오퍼레이션은 반환값을 Stream으로 주지 않는다.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        Stream<String> stringStream = friends.stream().filter(friend -> friend.equals("John"));
        Stream<String> stringStream1 = friends.stream().map(String::toUpperCase);
    }
}
  • 보면 filter(), map() 까지만 실행한 반환값은 모두 Stream이다. 즉, 이 두개는 중개 오퍼레이션이라는 말이다.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        List<String> friendsList = friends.stream()
                .filter(friend -> friend.equals("John"))
                .toList();
    }
}
  • 반면, toList()를 호출한 반환값은 Stream이 아니라 List이다. 즉, toList()는 종료 오퍼레이션이라는 말이다.
  • 참고로, forEach()도 종료 오퍼레이션이다. 얘는 반환값이 없기 때문에 헷갈릴 수 있어서 이렇게 꼭 집어서 말해봤다.

 

이제 중개 오퍼레이션과 종료 오퍼레이션의 차이를 알았다. 그럼 여기서 이제 "중개 오퍼레이션은 근본적으로 Lazy하다"라는 말은 무엇이냐면, 중개 오퍼레이션은 종료 오퍼레이션을 만나기 전까지 실제적으로 실행되지가 않는다! 다음 코드를 보자!

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        friends.stream()
                .filter(friend -> {
                    System.out.println("friend: " + friend);
                    return friend.equals("John");
                });
    }
}
  • 자, filter()는 중개 오퍼레이션이다. 이 filter() 이후로 종료 오퍼레이션은 없다. 즉, 여전히 이 녀석은 타입이 Stream인 상태이다. 그런데 내가 filter() 안에 각 요소들을 찍어보고 싶어서 `System.out.println("friend: " + friend)`를 작성한다고 이게 출력될까? 

실행 결과

위 실행 결과처럼 아무것도 출력하지 않는다. 디버깅해서 브레이크 포인트 걸어봐도 안 걸린다! 그래서! 정말 중요한 내용이다! 중개 오퍼레이션은 근본적으로 Lazy하다는 말은, 종료 오퍼레이션을 만나기 전까지 중개 오퍼레이션의 작업은 진행되지가 않는다는 말이다! 그럼 내가 저 코드에 종료 오퍼레이션을 넣고 실행하면 결과는 어떻게 될까?

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        friends.stream()
                .filter(friend -> {
                    System.out.println("friend: " + friend);
                    return friend.equals("John");
                }).toList();
    }
}
  • 딱 한 부분, toList()를 붙이는 것 말고 한 게 없다.

 실행 결과

 

정리

스트림 파이프라인은 0 또는 다수의 중개 오퍼레이션과 한개의 종료 오퍼레이션으로 구성되고, 스트림의 데이터 소스는 오직 종료 오퍼레이션을 실행할 때만 처리한다. 

 

 

손쉽게 병렬 처리를 할 수 있다.

이건 무슨말이냐면 이런 의문이 생길 것이다. "아니 뭐 굳이 스트림을 써? 그냥 for 루프 사용하면 되는거 아니야?" 맞다. 상관없다. 단순한 코드는 어떤걸 사용하더라도 뭐 크게 가시성이 달라지지도 않고 둘 다 읽기 편할 수 있다. 아래 코드를 보자.

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        friends.stream()
                .filter(friend -> friend.equals("John"))
                .forEach(System.out::println);

        for (String friend : friends) {
            if (friend.equals("John")) {
                System.out.println(friend);
            }
        }
    }
}
  • 하나는 스트림을 사용했고, 하나는 for 루프를 사용했다. 뭐 둘 다 읽기 편하고 아무런 문제도 없다. 
  • 실행 결과도 동일할 것이다.

 

그런데, 이런 경우가 있다. for 루프는 병렬 처리를 하기가 쉽지 않다. 위에 저 for 루프 안에서 요소 하나하나씩 순회하며 처리하지 저걸 병렬로 처리하고 있지 않는단 말이다. 그런데 스트림은 이걸 병렬로 처리하는게 굉장히 수월하다. 스트림은 요소 하나하나를 직렬로 어떤 행위를 처리할수도 있고 병렬로 어떤 행위를 처리할수도 있다. 

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        friends.parallelStream()
                .filter(friend -> friend.equals("John"))
                .forEach(System.out::println);
    }
}
  • 이렇게 stream() 대신에 parallelStream()을 사용하면 끝난다. 
  • 이럼 요소를 병렬로 처리하게 된다.

근데 또 눈으로 봐야만 믿는 사람들을 위해(나 포함) 아래와 같이 코드를 조금만 더 수정해보자.

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        friends.parallelStream()
                .filter(friend -> {
                    System.out.println("friend: " + friend + "||" + Thread.currentThread().getName());
                    return friend.equals("John");
                })
                .forEach(System.out::println);
    }
}
  • 실행하고 있는 쓰레드의 이름을 한번 찍어보자.

실행 결과

이 실행결과를 보면 알겠지만, 쓰레드의 이름이 다르다. 즉, 병렬처리가 제대로 되고 있다는 뜻이다. 이게 이제 손쉽게 병렬 처리를 할 수 있다는 말이다.

 

근데 손쉽게 병렬 처리를 한다고 마냥 좋을까?

지금 저 코드에서 병렬 처리를 하면 더 빠를까? 내가 볼땐 아니다. 이것도 눈으로 한번 확인해보자.

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        withParallelStream(friends);
        withForLoop(friends);
    }

    private static void withParallelStream(List<String> friends) {
        long startTime = System.currentTimeMillis();
        friends.parallelStream()
                .filter(friend -> friend.equals("John"))
                .forEach(System.out::println);
        long endTime = System.currentTimeMillis();
        System.out.println("parallelStream elapsed time: " + (endTime - startTime) + "ms");
    }

    private static void withForLoop(List<String> friends) {
        long startTime = System.currentTimeMillis();
        for (String friend : friends) {
            if (friend.equals("John")) {
                System.out.println(friend);
            }
        }
        long endTime = System.currentTimeMillis();
        System.out.println("for loop elapsed time: " + (endTime - startTime) + "ms");
    }
}
  • 두개를 실행해보자, 하나는 스트림으로 병렬처리를, 하나는 그냥 for 루프를 사용해서 동일한 작업을 하는데 시간을 측정해봤다.

실행 결과

엥?! 오히려 하나의 쓰레드로만 실행한게 더 빠르다. 왜 그럴까? 

 

→ 쓰레드를 여러개 사용하는데는 컨텍스트 스위칭 비용이 들기 때문이다. 또한, 쓰레드를 만들어내는 것도 리소스를 사용하는 것이기에 그렇다. 단순하게 생각해서 지금 저 `friends`라는 리스트는 그래봐야 개수가 4개밖에 없는 정말 작은 리스트이다. 그리고 요즘 컴퓨터는 1초에 연산을 몇번이나 할까? 수십억번을 한다. 수십억번. 4개 돌리는거? 일도 아니다. 그러니까 실제로 0ms가 걸린것이고. 그런데 4개 돌리는 그 와중에도 쓰레드를 3개를 더 만들어 총 4개를 사용하고, 그 4개를 돌려가며 사용하는 컨텍스트 스위칭 비용이 오히려 성능에 악화를 시키는 것이다. 그러니까 병렬 처리를 스트림으로 단순하게 할 수 있다고 해서 반드시 좋다고 생각하면 큰 오산이다. 이 경우가 그럼 언제 효율적일까? 데이터가 정말 많을때나 그 데이터로 처리하는 로직이 굉장히 복잡해서 하나의 쓰레드가 다 하는것보다 여러개의 쓰레드가 나눠 하는게 누가봐도 더 효율적일때 이 병렬 처리를 고려하면 좋다!

 

 

여러가지 Stream API

결론부터 말하면, 이거 외우는거 아니다. 쓰다보면 외워져서 바로 이거 쓰면 되겠구나! 싶은게 생길것이고, 모르면 이런거 있나? 찾아보면 된다. 진짜 여기서는 딱 두개만 해봐야겠다.

filter

말 그대로 뭔가 걸러내는 중개 오퍼레이션이다. 일단 코드로 바로 보자.

public class Shop {
    private String name;
    private boolean isOpen;

    public Shop() {
    }

    public Shop(String name, boolean isOpen) {
        this.name = name;
        this.isOpen = isOpen;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public boolean isOpen() {
        return isOpen;
    }

    public void setOpen(boolean open) {
        isOpen = open;
    }

    @Override
    public String toString() {
        return "Shop{" +
                "name='" + name + '\'' +
                ", isOpen=" + isOpen +
                '}';
    }
}
  • 우선 아주 간단한 클래스를 하나 만들었다. 가게에 대한 클래스고 가게 이름과 현재 열었는지 안 열었는지에 대한 필드들이 있다.
import java.util.ArrayList;
import java.util.List;

public class ShopMain {
    public static void main(String[] args) {
        Shop shopA = new Shop("A", true);
        Shop shopB = new Shop("B", true);
        Shop shopC = new Shop("C", false);
        Shop shopD = new Shop("D", true);

        List<Shop> shops = new ArrayList<>();
        shops.add(shopA);
        shops.add(shopB);
        shops.add(shopC);
        shops.add(shopD);

        shops.stream()
                .filter(Shop::isOpen)
                .forEach(System.out::println);
    }
}
  • 그리고 이렇게, 현재 오픈한 가게가 있는지를 찾아보는 간단한 스트림 API
  • 메서드 레퍼런스를 사용해서 매우매우 깔끔하게 작성했다.

실행 결과

 

 

근데, 이러고 싶을때가 있다. 안 열은 가게를 알고 싶은 경우가 있다. 이때는 역(Not)을 사용해야 하는데 그럼 이게 뭐가 살짝 불편하냐면 메서드 레퍼런스를 못쓴다. 다음과 같이 컴파일 오류가 난다. 

 

이럴때, 조금 더 이쁘게 작성할 수가 있는데, 이전 포스팅에서 배운 Predicate<T>을 사용하는 것이다! 왜냐하면 filter가 받는 타입 자체가 Predicate<T>이기 때문에 더할 나위없이 완벽하다. 그래서 이렇게 작성할 수 있다.

shops.stream()
        .filter(Predicate.not(Shop::isOpen))
        .forEach(System.out::println);

 

앞으로, 역(Not)과 메서드 레퍼런스를 같이 사용하고 싶을때 이 Predicate<T>을 적극 활용하자!

 

flatMap

flatMapmap의 추가 기능이라고 보면 된다. map이 뭐냐? 인풋을 받아 아웃풋으로 돌려준다. 그래서 인자의 타입도 우리가 배운 Function<T, R>이다. flatMap도 어떤 값(A)을 받아 어떤 값(B)로 만들어준다. 그런데 앞에 flat이 붙었다. 이건 뭐냐면, 리스트를 받아서 그 리스트의 요소를 다 풀어버리는 것이다. 코드로 보자.

 

import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;

public class ShopMain {
    public static void main(String[] args) {
        Shop shopA = new Shop("A", true);
        Shop shopB = new Shop("B", true);
        Shop shopC = new Shop("C", true);
        Shop shopD = new Shop("D", true);

        List<Shop> openedShops = new ArrayList<>();
        openedShops.add(shopA);
        openedShops.add(shopB);
        openedShops.add(shopC);
        openedShops.add(shopD);

        Shop shopE = new Shop("E", false);
        Shop shopF = new Shop("F", false);
        Shop shopG = new Shop("G", false);
        Shop shopH = new Shop("H", false);

        List<Shop> closedShops = new ArrayList<>();
        closedShops.add(shopE);
        closedShops.add(shopF);
        closedShops.add(shopG);
        closedShops.add(shopH);

        List<List<Shop>> allShops = new ArrayList<>();
        allShops.add(openedShops);
        allShops.add(closedShops);

        allShops.stream()
                .flatMap(shops -> shops.stream())
                .forEach(shop -> System.out.println(shop.getName()));
    }
}
  • openedShops, closedShops 두 개의 리스트가 있다.
  • 그리고 이 각각 리스트를 리스트로 담은 allShops가 있다.
  • 그럼 allShops는 요소가 각각이 리스트이다.
  • allShops를 가지고 스트림을 돌리면 각 요소 하나하나가 리스트인데 flatMap을 사용해서 이 리스트를 풀어 헤치는 것이다. 그래서 그 하나의 요소인 리스트 전체를 다 돌고, 그 다음 하나의 요소인 리스트 전체를 다 돌 수 있게 말이다.

실행 결과

 

요 녀석을 더 깔끔하게 이렇게 바꿀 수도 있다.

//allShops.stream()
//        .flatMap(shops -> shops.stream())
//        .forEach(shop -> System.out.println(shop.getName()));
        
        
allShops.stream()
        .flatMap(Collection::stream)
        .forEach(shop -> System.out.println(shop.getName()));

 

 

이 정도만 알아보고 나머지 여러 기능들은 공식 문서를 통해 필요할 때 찾아서 사용하면 된다. 다시 말하지만 이걸 외우는거 아니다! 안 외워도 자주 사용하는건 저절로 외워지기도 하고 필요한게 생기면 찾아서 사용하면 된다! 다음 공식 문서에서.

 

Stream (Java Platform SE 8 )

A sequence of elements supporting sequential and parallel aggregate operations. The following example illustrates an aggregate operation using Stream and IntStream: int sum = widgets.stream() .filter(w -> w.getColor() == RED) .mapToInt(w -> w.getWeight())

docs.oracle.com

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

[Java 8] CompletableFuture  (0) 2024.11.30
[Java 8] Optional  (0) 2024.11.27
[Java 8] 함수형 인터페이스와 람다 표현식  (0) 2024.11.25
애노테이션  (6) 2024.10.21
리플렉션  (6) 2024.10.21
728x90
반응형
SMALL

자바8부터 나타난 함수형 인터페이스에 대해 알아보는 시간을 가져보자.

 

함수형 인터페이스 (Functional Interface)

  • 추상 메서드를 딱 하나만 가지고 있는 인터페이스를 말한다. (SAM 인터페이스라고도 한다) (SAM = Single Abstract Method)
  • @FunctionalInterface 애노테이션을 가지고 있는 인터페이스를 말한다.

다음 코드를 보자.

@FunctionalInterface
public interface Hello {
    void hello();
}
  • 위 코드처럼 추상 메서드가 딱 하나만 있을때 이 인터페이스를 함수형 인터페이스라고 한다.
  • 그리고 함수형 인터페이스를 정의할 일이 있다면 @FunctionalInterface 애노테이션을 사용해서 이 인터페이스가 함수형 인터페이스임을 명확히 하는게 좋다. 왜냐하면 추상 메서드가 한개가 아닌 경우 아래 사진처럼 컴파일 에러를 만들어주기 때문이다. 

 

 

이런 경우도 함수형 인터페이스이다.

@FunctionalInterface
public interface Hello {
    void hello();
    
    int h = 100;

    static void howAreYou() {
        System.out.println("how are you");
    }

    default void hi() {
        System.out.println("Hello");
    }
}
  • 자바8부터 인터페이스에 이렇게 메서드를 직접 정의할수도 있게 됐는데 그런것이 몇 개가 있던 결국 가장 중요한건 추상 메서드가 딱 한개이냐 아니냐로 함수형 인터페이스는 정의된다.
  • 여기서도 마찬가지로 abstract void hello(); 하나뿐이므로 이 인터페이스는 함수형 인터페이스이다.
  • 참고로, 인터페이스에서 abstract는 생략 가능하다.

 

그럼 이렇게 정의한 함수형 인터페이스를 어떻게 사용하면 되느냐? 다음 코드를 보자.

public class FIMain {
    public static void main(String[] args) {
        Hello h = new Hello() {
            @Override
            public void hello() {
                System.out.println("hello");
            }
        };

        h.hello();
    }
}
  • 자바8 이전에는 이런식으로 사용했다. 이거를 익명 내부 클래스라고 했었다. 굳이 구현체를 클래스로 따로 만들지 않아도 이렇게 작성하면 되니까 익명 내부 클래스라는 이름이 붙은 것 같다.
  • 지금도 당연히 이렇게 사용할 수 있다.

그런데 이 코드를 획기적으로 줄일 수가 있다. 다음 코드를 보자.

public class FIMain {
    public static void main(String[] args) {
        Hello h = () -> System.out.println("hello");

        h.hello();
    }
}
  • 단 한줄로 변경된 모습이 보이는가? 이게 함수형 인터페이스를 람다로 표현한다고 한다. 
  • 물론, 실행 부분이 저렇게 딱 한줄일때 이렇게 사용할 수 있고 만약 한 줄 이상이라면 다음과 같이 사용하면 된다.
public class FIMain {
    public static void main(String[] args) {
        Hello h = () -> {
            System.out.println("hello");
            System.out.println("Hi");
        };

        h.hello();
    }
}
  • 그럼에도 불구하고 익명 내부 클래스보단 획기적으로 코드양이 줄어 보인다.
  • 이런 표현식을 람다 표현식이라고 한다.

 

자바에서 제공하는 함수형 인터페이스

개발자가 직접 구현하지 않고 자바에서 자체적으로 제공해주는 함수형 인터페이스가 있다. 바로 다음 패키지에 있는 함수형 인터페이스들이다.

java.util.function

 

 

Java Platform SE 8

 

docs.oracle.com

 

위 패키지에서 여러가지 함수형 인터페이스가 있는데 대표적인 것들을 살펴보자.

  • Function<T, R>
  • BiFunction<T, U, R>
  • Consumer<T>
  • Supplier<T>
  • Predicate<T>
  • UnaryOperator<T>
  • BinaryOperator<T>

 

Function<T, R>

이 함수형 인터페이스는 인풋 T를 받고, 아웃풋 R을 반환한다. 코드로 바로 알아보자.

import java.util.function.Function;

public class FIMain {
    public static void main(String[] args) {
        Function<Integer, Integer> plus = (number) -> number + 1;
        
        System.out.println(plus.apply(2));
    }
}
  • Integer를 받아서 Integer를 반환하는 plus 라는 이름의 함수형 인터페이스 Function을 구현했다.
  • 이 함수형 인터페이스는 apply라는 추상 메서드를 구현해야 하고 그것을 우리는 받은 인자에 + 1을 한 값을 리턴하는 것으로 구현했다.
  • 이 녀석의 apply(2)를 실행하면 결과는 3이 나오게 된다.

실행 결과

 

 

이런식으로도 작성할 수 있겠다.

import java.util.function.Function;

public class Main {
    public static void main(String[] args) {
        Function<Integer, Integer> plusWithOne = (x) -> x + 1;
        Function<Integer, Integer> multiplyWithTwo = (x) -> x * 2;

        System.out.println(multiplyWithTwo.apply(2));
    }
}
  • 인자값을 받아 2를 곱하는 함수형 인터페이스 Function을 구현했다.
  • 실행하면 당연히 결과는 4가 나온다.

실행 결과

 

 

그런데, 이 두개의 Function을 조합할수도 있다. Function에서 제공하는 compose()라는 메서드가 있다.

import java.util.function.Function;

public class Main {
    public static void main(String[] args) {
        Function<Integer, Integer> plusWithOne = (x) -> x + 1;
        Function<Integer, Integer> multiplyWithTwo = (x) -> x * 2;
        
        Function<Integer, Integer> compose = plusWithOne.compose(multiplyWithTwo);
        System.out.println(compose.apply(2));
    }
}
  • 이렇게 composeFunctionFunction을 조합하는 게 가능하다.
  • compose는 인자로 받은 Function을 먼저 apply 수행하고, 그 결과값을 다시 apply해서 결과를 반환한다.
  • 그러니까, compose.apply(2)를 하면, 먼저 multiplyWithTwo를 실행해서 4를 만들고, 그 결과값을 plusWithOne.apply()에 인자로 넣어 실행하게 된다. 그래서 결과는 5가 나온다.

실행 결과

 

 

이번에는 이런 것도 있다.

import java.util.function.Function;

public class Main {
    public static void main(String[] args) {
        Function<Integer, Integer> plusWithOne = (x) -> x + 1;
        Function<Integer, Integer> multiplyWithTwo = (x) -> x * 2;

        Function<Integer, Integer> andThen = plusWithOne.andThen(multiplyWithTwo);
        System.out.println(andThen.apply(5));
    }
}
  • andThen(). 이것도 역시 두 Function을 조합하는데, 이건 받은 Function이 뒤에 실행된다.
  • 그러니까, 먼저 plusWithOne.apply()가 실행되고, 그 실행된 결과값을 multiplyWithTwo.apply()인자로 받아 실행된 결과값을 반환한다. 그래서 결과는 12가 나온다.

실행 결과

 

BiFunction<T, U, R>

BiFunctionFunction이랑 똑같은데, 인자값을 2개(T, U) 받는다. 그래서 R을 반환한다.

import java.util.function.BiFunction;

public class Main {
    public static void main(String[] args) {
        BiFunction<Integer, Integer, Integer> add = (x, y) -> x + y;
        System.out.println(add.apply(1, 2));
    }
}
  • 이런식으로 작성할 수 있고, 결과는 3이 나오게 된다.

실행 결과

 

 

Consumer<T>

이건 반환값이 없다. 추상 메서드의 반환 타입이 void다. 이름 그대로 소비자의 역할을 한다고 보면 된다.

import java.util.function.Consumer;

public class Main {
    public static void main(String[] args) {
        Consumer<Integer> printer = (x) -> System.out.println(x);
        printer.accept(1);
    }
}
  • 얘는 추상 메서드 명이 accept()이다. 그리고 위와 같이 반환값이 없다. 간단하게 받은 파라미터를 출력하도록 작성했다.

실행 결과

 

Supplier<T>

이 녀석은 반대다. 받는게 없고 반환만 한다. 이름 그대로 공급자의 역할을 한다고 보면 된다.

import java.util.function.Supplier;

public class Main {
    public static void main(String[] args) {
        Supplier<Integer> get10 = () -> 10;
        System.out.println(get10.get());
    }
}
  • 얘는 추상 메서드 명이 get()이다. 공급자라는 이름에 아주 걸맞는 메서드명이다.
  • 위 코드처럼 받는것은 없고 반환만 한다. 

 

Predicate<T>

이 녀석은, 어떤 인자값을 받아 boolean 타입을 반환한다.

import java.util.function.Predicate;

public class Main {
    public static void main(String[] args) {
        Predicate<Integer> isOne = (x) -> x == 1;
        System.out.println(isOne.test(1));
        System.out.println(isOne.test(2));
    }
}
  • 위 코드와 같이 인자값을 받아서 그 인자값이 1인지 확인하는 뭐 이런 코드를 작성할 수 있겠다.
  • 추상 메서드의 이름은 test()이다.

실행 결과

 

 

그리고 이 녀석은 반환값이 boolean이기 때문에 이런 조합 메서드를 제공한다.

  • and()
  • or()
  • negate()
import java.util.function.Predicate;

public class Main {
    public static void main(String[] args) {
        Predicate<Integer> isOne = (x) -> x == 1;
        Predicate<Integer> isOdd = (x) -> x % 2 != 0;

        Predicate<Integer> andPredicate = isOne.and(isOdd);
        System.out.println(andPredicate.test(1));
    }
}
  • Predicate을 조합하는데 AND 조건으로 조합해서 결과를 반환한다. 
  • 위 코드를 보면 하나는 받은 인자가 1인지, 하나는 받은 인자가 홀수인지를 체크하는 Predicate이다.
  • 이 두개를 AND로 조합해서 둘 다 true를 반환하면 true를 반환하고 둘 중 하나라도 false라면 false를 반환한다.

실행 결과

 

 

그럼 or(), negate()은 예측이 가능하다. 

import java.util.function.Predicate;

public class Main {
    public static void main(String[] args) {
        Predicate<Integer> isOne = (x) -> x == 1;
        Predicate<Integer> isOdd = (x) -> x % 2 != 0;

        Predicate<Integer> orPredicate = isOne.or(isOdd);;
        System.out.println(orPredicate.test(3));
    }
}
  • 하나는 1인지, 하나는 홀수인지를 체크하는 Predicate인데 이 두 개를 or()로 연결했다.
  • 둘 중 하나라도 true라면 true를 반환하고, 둘 중 하나라도 false라면 false를 반환할 것이다.

실행 결과

 

import java.util.function.Predicate;

public class Main {
    public static void main(String[] args) {
        Predicate<Integer> isOne = (x) -> x == 1;
        Predicate<Integer> isOdd = (x) -> x % 2 != 0;

        Predicate<Integer> negate = isOne.negate();
        System.out.println(negate.test(1));
    }
}
  • negate()은 역이다. true라면 false를, false라면 true를 반환한다.

실행 결과

 

 

UnaryOperator<T> 

이 녀석은 편리함을 제공해주는 녀석이다. 다음 코드를 보자.

import java.util.function.Function;

public class Main {
    public static void main(String[] args) {
        Function<Integer, Integer> plusWithOne = (x) -> x + 1;
    }
}
  • 위 코드처럼 입력값과 반환값의 타입이 동일한 경우에 다음과 같이 사용할 수가 있다.
import java.util.function.UnaryOperator;

public class Main {
    public static void main(String[] args) {
        // Function<Integer, Integer> plusWithOne = (x) -> x + 1;
        UnaryOperator<Integer> plusWithOne = (x) -> x + 1;
    }
}
  • 입력값과 반환값이 같은 경우, UnaryOperator를 사용해서 좀 더 간결하게 작성할 수가 있다.
  • 그리고 이 UnaryOperatorFunction을 상속받는다. 그래서 Function이 제공하는 조합 메서드(compose(), andThen(), ...)을 사용할 수 있다.

 

 

BinaryOperator<T>

이 녀석은 BiFunction<T, U, R>의 간편 메서드라고 보면 된다. 이 BiFunction은 두 개의 인자를 받아 R타입을 반환하는 녀석이다. 근데 이 세 개의 타입(T, U, R)이 모두 같은 경우 이 BinaryOperator<T>를 사용할 수가 있다.

import java.util.function.BinaryOperator;

public class Main {
    public static void main(String[] args) {
        // BiFunction<Integer, Integer, Integer> add = (x, y) -> x + y;
        BinaryOperator<Integer> add = (x, y) -> x + y;
    }
}

 

 

이렇게 대표적인 것들을 알아봤는데 직접 저 패키지 안으로 들어가보면, 이 말고도 굉장히 뭐가 많다. 근데 지금까지 배운것들의 응용이라고 생각하면 된다. 이름만 봐도 "아 이건 이거겠구나!"를 추측할 수 있을 것이다. 예를 들어, 이런 거다.

직접 이 패키지에 뭐가 있는지 쭉 보면, 맨 위에 BiConsumer<T, U>가 있다. 그럼 우린 Consumer<T>를 배웠기 때문에 이게 뭔지 추측이 가능하다. "아, 두 개의 인자를 받아서 아무것도 반환은 안하고 TU를 가지고 뭔가를 하겠구나?" 맞다. 

 

변수 캡처

이 부분은 꽤나 중요하다. 자세히 들여다보자. 함수형 인터페이스와 람다 표현식을 사용할때 주의할 점이 있다. 바로 이 변수 캡처에 대한 내용인데 다음 코드를 보자.

import java.util.function.Consumer;

public class Main {
    public static void main(String[] args) {
        final int number = 10;

        // 로컬 클래스
        class LocalClass {
            void local() {
                System.out.println(number);
            }
        }

        // 익명 내부 클래스
        Consumer<Integer> consumer = new Consumer<Integer>() {
            @Override
            public void accept(Integer integer) {
                System.out.println(number);
            }
        };

        // 람다
        Consumer<Integer> lambda = (x) -> System.out.println(x + number);
        lambda.accept(number);
    }
}
  • 로컬 클래스든, 익명 내부 클래스든, 람다 표현식이든 변수를 참조를 할수가 있다. 근데 자바8 이전에는 그 변수는 반드시 final 키워드가 붙어야 했다. 즉, 선언 이후 절대로 변경되지 않을 변수만 참조가 가능하다.
  • 그런데, 자바8 이후부터는 이런 경우에 final을 붙이지 않아도 참조할 수 있다. 다음 코드를 보자. 
import java.util.function.Consumer;

public class Main {
    public static void main(String[] args) {
        int number = 10;

        // 로컬 클래스
        class LocalClass {
            void local() {
                System.out.println(number);
            }
        }

        // 익명 내부 클래스
        Consumer<Integer> consumer = new Consumer<Integer>() {
            @Override
            public void accept(Integer integer) {
                System.out.println(number);
            }
        };

        // 람다
        Consumer<Integer> lambda = (x) -> System.out.println(x + number);
        lambda.accept(number);
    }
}
  • 이 역시 문법적으로 아무런 문제도 되지 않는 올바른 코드이다. 이게 이제 가능한데 이거를 "사실상 final" 이라고 표현한다.
  • "사실상 final" 이란 말은, final 이라는 키워드는 붙지 않았지만, 이 값이 final처럼 취급되는 경우를 말한다.
  • 여기서 만약, 이후에 값을 변경하려고 하면 어떻게 될까? 아래와 같이 컴파일 오류가 발생한다.

즉, 선언한 후 값이 변경되지 않는 변수에 대해서는 로컬 클래스든, 익명 내부 클래스든, 람다든 변수를 참조할 수 있는데 이것을 변수 캡처라고 한다. 그런데 여기서 더 중요한 사실이 있다. [로컬 클래스, 익명 내부 클래스]와 람다는 차이가 있다.

 

다음 코드를 보자.

import java.util.function.Consumer;

public class Main {
    public static void main(String[] args) {
        final int number = 10;

        // 로컬 클래스
        class LocalClass {
            final int number = 55;
            void local() {
                int number = 30;
                System.out.println(number);
            }
        }

        new LocalClass().local();

        // 익명 내부 클래스
        Consumer<Integer> consumer = new Consumer<Integer>() {
            final int number = 40;
            @Override
            public void accept(Integer integer) {
                System.out.println(number + integer);
            }
        };
        consumer.accept(4);

        // 람다
        Consumer<Integer> lambda = (x) -> System.out.println(x + number);
        lambda.accept(5);
    }
}
  • 벌써 어지럽다. 근데, 결론적으로 로컬 클래스와 익명 내부 클래스는 로컬 변수를 가질 수가 있다.
  • 위 코드를 보면 바깥에 있는 main 메서드에 number라는 변수를 선언했는데, 로컬클래스에서 local() 메서드 안에 또 다른 로컬 변수 number를 선언했다.
  • 위 코드를 보면 바깥에 있는 main 메서드에 number라는 변수를 선언했는데, 익명 내부 클래스 안에서 또 다른 로컬 필드인 number를 선언했다. 
  • 이 경우에는 Scope이 어떻게 결정될까? 실행 결과를 보자.

  • 이러한 결과가 나왔다. 어찌보면 당연한 것이다. 물론, 익명 내부 클래스도 accept() 메서드 안에 로컬 변수로 선언해도 상관없다. 로컬 클래스와 같이 더 가까이에 있는게 적용된다. 

 

그런데, 람다는 아니다! 람다는 Scope이 람다만의 Scope은 없다. main 메서드와 Scope을 같이한다. 즉, 람다를 감싸고 있는 녀석과 같다는 말이다. 그러니까 아래 사진을 보자. 컴파일 오류가 난다. 당연하다. 왜냐? 같은 Scope에 동일한 이름의 변수를 만들 수 없는건 너무 당연하니까!

 

메서드 레퍼런스

메서드 레퍼런스는 뭘까? 다시 한번 아래 코드를 보자.

import java.util.function.Consumer;

public class Main {
    public static void main(String[] args) {
        Consumer<Integer> lambda = (x) -> System.out.println(x);
        lambda.accept(5);
    }
}
  • Consumer를 람다 표현식으로 만들었다. 그리고 바디에서 하는 일은 입력값을 그대로 시스템 콘솔에 출력한다.
  • 그런데 이렇게 람다의 바디에서 하는 일이 기존 메서드 또는 생성자를 호출하는 거라면, 메서드 레퍼런스를 사용해서 매우 간결하게 표현할수가 있는데 다음 코드를 보자.
import java.util.function.Consumer;

public class Main {
    public static void main(String[] args) {
        Consumer<Integer> lambda = System.out::println;
        lambda.accept(5);
    }
}
  • 이게 바로 메서드 레퍼런스이다. 훨씬 더 깔끔해졌다. 그냥 System.outprintln()을 호출하는 것 뿐이고 그런 경우에는 이렇게 메서드 레퍼런스로 매우 간결하게 출력할 수 있다. 
  • 당연히 출력하는 값은 Consumer는 입력값을 받기 때문에 입력값을 그대로 출력한다.

실행 결과

 

 

굳이 따지자면, 이렇게 4가지가 가능하다.

  • 타입::스태틱 메서드
  • 객체 레퍼런스::인스턴스 메서드
  • 타입::인스턴스 메서드
  • 타입::new

타입::스태틱 메서드

public class MethodRef {

    public static String staticMethod(String s) {
        return "Hello " + s;
    }
}

자, 위와 같은 클래스가 있다고 해보자.

 

import java.util.function.UnaryOperator;

public class Main {
    public static void main(String[] args) {
        UnaryOperator<String> lambda = MethodRef::staticMethod;
        System.out.println(lambda.apply("World"));
    }
}
  • 똑같이 String을 받아서 String을 반환한다면, 타입::스태틱 메서드 형태로 표현할 수 있다.

실행 결과

 

객체 레퍼런스::인스턴스 메서드

public class MethodRef {

    public String instanceMethod(String s) {
        return "Hello " + s;
    }

    public static String staticMethod(String s) {
        return "Hello " + s;
    }
}

 

 

import java.util.function.UnaryOperator;

public class Main {
    public static void main(String[] args) {
        MethodRef methodRef = new MethodRef();

        UnaryOperator<String> lambda = methodRef::instanceMethod;
        System.out.println(lambda.apply("World"));
    }
}
  • 위 코드처럼 인스턴스가 있고, 인스턴스의 메서드를 가지고 람다 표현식을 깔끔하게 쓸 수 있다.

실행 결과

 

 

타입::new

public class MethodRef {

    private String s;

    public MethodRef() {
    }

    public MethodRef(String s) {
        this.s = s;
    }

    public String getS() {
        return s;
    }

    public String instanceMethod(String s) {
        return "Hello " + s;
    }

    public static String staticMethod(String s) {
        return "Hello " + s;
    }
}

자, 이번엔 필드하나를 추가하고 생성자 두개를 추가했다.

 

import java.util.function.Supplier;

public class Main {
    public static void main(String[] args) {
        Supplier<MethodRef> methodRef = MethodRef::new;
        MethodRef methodRefBySupplier = methodRef.get();
    }
}
  • 이런게 가능하다. 생성자를 호출할 수도 있다.
  • 여기서 호출한 생성자는 뭘까 그럼? Supplier는 아무런 인자도 받지 않는다. 그리고 반환하는 것이 MethodRef다. 이런 경우엔? 저기 위에서 선언한 기본생성자를 호출하는 것이다.
import java.util.function.Function;

public class Main {
    public static void main(String[] args) {
        Function<String, MethodRef> methodRef = MethodRef::new;
        MethodRef hello = methodRef.apply("hello");

        System.out.println(hello.getS());
    }
}
  • 이번에도 역시 생성자를 호출하는데, 지금 보면 Function으로 되어 있다. 즉, 받는 값이 있고 반환하는 값이 있는건데, 받는 값이 String이고 반환값이 MethodRef다. 이 경우에는 당연히 기본 생성자가 아니라 String을 받는 생성자를 호출하는 것이다!
  • 실제로 apply("hello")를 호출했을때, 필드 `s`에 저 값이 들어가는지 확인해보면 다음과 같다.

실행 결과

 

 

타입::인스턴스 메서드

import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        String[] fruit = {"apple", "tomato", "orange"};
        Arrays.sort(fruit, String::compareToIgnoreCase);
    }
}
  • 이런게 타입::인스턴스 메서드이다. 
  • 보면, Arrays.sort()를 사용해서 배열을 받고 정렬을 하고 있다.
  • 정렬할때 두번째 인자로, Comparator를 넣어줘야 하는데, 보통은 이제 아래와 같이 사용을 할 것이다.
import java.util.Arrays;
import java.util.Comparator;

public class Main {
    public static void main(String[] args) {
        String[] fruit = {"apple", "tomato", "orange"};
        
        Comparator<String> comparator = (o1, o2) -> o1.compareTo(o2);
        Arrays.sort(fruit, comparator);
    }
}
  • 그런데 이제, 이렇게 사용이 가능하다는 것이다.
Arrays.sort(fruit, String::compareToIgnoreCase);
  • 이건 이제, fruit 안에 있는 문자열 두개를 가져와서 서로 비교를 하고 있는건데, String 이라는 타입의 "apple", "tomato", "orange"라는 인스턴스의 메서드인 compareToIgnore()을 사용하고 있는 타입::인스턴스 메서드이다.

 

인터페이스 기본 메서드와 스태틱 메서드

또한, 자바8부터 제공되는 인터페이스의 기본 메서드와 스태틱 메서드도 알아보자. 자 다음 코드를 보자.

public interface Hello {
    void printHello();
}
  • Hello 라는 인터페이스 하나가 있다.
public class HelloImpl implements Hello {
    @Override
    public void printHello() {

    }
}
  • Hello 를 구현한 HelloImpl 클래스가 있다.

 

그런데 만약 이때, Hello를 구현하는 모든 구현체는 다 동일하게 어떤 메서드를 가지고 싶게 하려면 어떻게 하면 될까? 모두 같은 기능을 하는 메서드인데 인터페이스에 또 추상 메서드를 하나 선언해버리면? 다음과 같은 컴파일 오류가 날 것이다.

public interface Hello {
    void printHello();

    void printHi();
}

 

이런 경우에 자바8부터는 인터페이스에 기본 메서드를 만들 수 있도록 했다. 바로 이렇게 말이다.

public interface Hello {
    void printHello();

    default void printHi() {
        System.out.println("Hi");
    }
}
  • 이렇게 default 라는 키워드를 사용하여 메서드를 직접 인터페이스에서 구현할 수 있게 했고, 이때 이 인터페이스를 구현하는 구현체는 모두 동일하게 이 메서드를 사용할 수 있다.
public class HelloImpl implements Hello {
    @Override
    public void printHello() {
        printHi();
    }
}
  • 조금 모양새가 웃기긴 하지만, printHello() 메서드 안에 기본 메서드인 printHi()를 호출하고 있다.

 

기본 메서드의 탄생 취지

→ 해당 인터페이스를 구현한 클래스를 깨뜨리지 않고 모든 구현한 클래스에 동일한 새 기능을 추가할 수 있다.

 

그런데, 이 기본 메서드는 구현체가 모르게 추가된 기능이기 때문에 그만큼 리스크가 있다. 어떤 리스크가 있을까? 

기본 메서드의 리스크

public interface Hello {
    void printHello();

    default void printHi(String yourName) {
        System.out.println(yourName.toUpperCase() + ", Hello!");
    }
}
  • 예를 들어, 기본 메서드를 하나 만들었는데 파라미터로 이름을 받는다. 그리고 그 이름을 toUpperCase()를 호출하는데 만약 넘겨진 이름이 null 이라면? 런타임 오류가 발생할 수 있다.
  • 그래서, 최대한 이런 예측 못한 에러를 방지할 수 있도록 다음과 같이 문서화 하는 것을 꼭 염두하자. 
public interface Hello {
    void printHello();

    /**
     * @implSpec 파라미터로 이름을 받아서 해당 이름을 대문자로 변경한 후, ", Hello!"를 붙여 출력한다. 따라서 이름은 반드시 필수로 받아야 한다.
     * @param yourName 이름 
     */
    default void printHi(String yourName) {
        System.out.println(yourName.toUpperCase() + ", Hello!");
    }
}

 

기본 메서드의 여러가지 규칙들

기본 메서드를 만들 때, Object가 제공하는 기능(toString(), equals(), ...)은 기본 메서드로 제공할 수 없다. 기본 메서드로 선언하는 순간 다음과 같은 컴파일 오류가 발생한다.

 

인터페이스를 상속받는 인터페이스에서 다시 추상 메서드로 변경도 가능하다.

public interface Hello {
    void printHello();

    /**
     * @implSpec 파라미터로 이름을 받아서 해당 이름을 대문자로 변경한 후, ", Hello!"를 붙여 출력한다. 따라서 이름은 반드시 필수로 받아야 한다.
     * @param yourName 이름
     */
    default void printHi(String yourName) {
        System.out.println(yourName.toUpperCase() + ", Hello!");
    }
}

 

public interface HelloExtends extends Hello {

    void printHi(String yourName);
}
  • Hello를 상속받는 HelloExtendsprintHi를 다시 추상 메서드로 변경했다.
  • 이제 이 HelloExtends를 구현하는 구현체는 반드시 printHi(String yourName)을 구현해야 한다.

 

두 개의 인터페이스가 같은 시그니처를 갖는 기본 메서드를 제공할 경우에는 그 두 인터페이스를 둘 다 구현하는 구현체는 무조건 동일한 시그니처를 갖는 기본 메서드를 재정의해야 한다.

예를 들어 아래 코드를 보자.

public interface Hello {
    void printHello();

    /**
     * @implSpec 파라미터로 이름을 받아서 해당 이름을 대문자로 변경한 후, ", Hello!"를 붙여 출력한다. 따라서 이름은 반드시 필수로 받아야 한다.
     * @param yourName 이름
     */
    default void printHi(String yourName) {
        System.out.println(yourName.toUpperCase() + ", Hello!");
    }
}

 

public interface Hi {
    default void printHi(String yourName) {
        System.out.println(yourName.toUpperCase() + ", Hi!");
    }
}

 

  • Hello, Hi를 둘 다 구현하는 HelloHiImpl 클래스는 오류가 발생한다. 왜냐하면, 완전히 동일한 시그니처를 가지는 기본 메서드가 둘 다 있기 때문에 어떤걸 사용해야 할지 컴파일러는 애매하기 때문이다. 그래서 이런 경우에는 반드시 해당 기본 메서드를 재정의 해야 한다. 아래와 같이.
public class HelloHiImpl implements Hello, Hi {
    @Override
    public void printHello() {

    }

    @Override
    public void printHi(String yourName) {
        System.out.println("HH");
    }
}

 

 

이제 스태틱 메서드를 알아볼건데 이건 뭐 없다. 그냥 인터페이스에 스태틱 메서드를 만들 수 있다가 끝이다.

public interface Hello {

    static String returnString(String a) {
        return a + "string";
    }
}
  • 이렇게 인터페이스에도 스태틱 메서드를 만들 수가 있다. 
public class Main {
    public static void main(String[] args) {
        String gg = Hello.returnString("gg");
        System.out.println(gg);
    }
}
  • 사용하는 것도 스태틱 메서드 사용하는것 그대로 동일하다.

인터페이스의 기본 메서드가 가져온 새로운 혁신

인터페이스의 기본 메서드가 생기고 나서부터 엄청난 혁신이 생기게 됐다. 자바8 이전에는 인터페이스가 이렇게 있었다면,

public interface Something {
    void a();
    void b();
    void c();
}

이 인터페이스를 구현하는 구현체 하나를 두기도 했다.

public abstract class SomethingAbstract implements Something {
    @Override
    public void a() {
        
    }

    @Override
    public void b() {

    }

    @Override
    public void c() {

    }
}
  • 왜 이랬을까? 저 인터페이스를 구현하는 구현체에게 편리함을 제공하기 위해서다.
  • 자바8 이전에는 저 인터페이스를 구현하는 구현체는 좋든 싫든 a(), b(), c()를 모두 구현해야만 했다.
  • 그게 너무 싫으니, 이렇게 아무것도 없는 껍데기뿐인 Abstract 클래스를 하나 만들고 인터페이스를 구현하게 하고 이 클래스를 상속받는 클래스를 만들어서 본인이 원하는 메서드만 구현하게 했던 것이다.
public class SomethingA extends SomethingAbstract {
    @Override
    public void a() {
        System.out.println("SomethingA");
    }
}
  • 이렇게 말이다. 
  • 그런데 이제는 인터페이스에서 기본 메서드를 구현할 수 있으니 이런 불편함을 없애는것과 동시에 혁신적 혁명이 일어난다.
  • 왜 혁명일까? 인터페이스는 상속이 아니라 구현이기 때문에 아무리 많이 구현해도 상관이 없고 상속의 강제화에서 벗어날 수 있기 때문이다. 

 

이러한 이유로 인터페이스의 기본 메서드는 라이브러리나 프레임워크를 만들때 굉장히 자주 빈번하게 사용되는 것을 볼 수 있다. 스프링도 그렇다. 

 

 

자바 API 기본 메서드

자바8부터 굉장히 많은 것들이 추가가 됐다. 그 중 대표적인 게 Stream API인데, 이건 이제는 너무 중요하기도 하고 자주 사용되기 때문에 아예 포스팅 하나를 새로 만들어서 이것만 다뤄보기로 할것이고 여기서는 맛보기 정도를 해보자.

 

Iterable의 기본 메서드

  • forEach()
  • spliterator()

이 두개를 한번 맛보자. forEach()는 순회하는 기능인데, 이거 보면 재밌다.

  • forEach()가 인자로 무엇을 받고 있나? 바로 Consumer다. 위에서 배운 Consumer.
  • Consumer를 받는다는 것은 람다로 표현할 수 있다는 뜻이고, 인자를 받지만 반환은 하지 않는 그런 함수형 인터페이스이다.
  • 그래서 다음과 같이 사용할 수 있다.
friends.forEach(friend -> System.out.println(friend));
  • Consumer를 배우니까 이게 어떤것인지 너무나 명확하게 이해가 된다.
  • 순회하는 각 요소를 Consumer의 인자로 주고 그 안에서 무언가를 하지만 반환은 하지 않는 것이다.
  • 그리고, 저렇게 작성하면? 그렇다. 메서드 레퍼런스를 사용해서 더 간단하게 축약할 수 있다.
friends.forEach(System.out::println);

 

spliterator()는 자주 사용되는 것은 아니지만, 알아둬서 나쁠건 없다. 이 녀석 역시 순회를 하는데 이 녀석도 재밌다.

import java.util.ArrayList;
import java.util.List;
import java.util.Spliterator;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        Spliterator<String> spliterator = friends.spliterator();
        while (spliterator.tryAdvance(System.out::println));
    }
}
  • 역시 마찬가지로 순회를 하는데 얘는 꼭 next()를 호출하는 것 같은 생김새다.
  • 이 친구는 next() 대신 tryAdvance()를 사용하는데 그 안에 어떤 작업을 할지도 작성할 수 있다.
  • 그리고 이름에 split이 있는거 보니, 쪼갤 수도 있는 것 같다. 맞다. 
import java.util.ArrayList;
import java.util.List;
import java.util.Spliterator;

public class Main {
    public static void main(String[] args) {
        List<String> friends = new ArrayList<>();
        friends.add("John");
        friends.add("Jane");
        friends.add("Bob");
        friends.add("Mary");

        Spliterator<String> spliterator = friends.spliterator();
        Spliterator<String> stringSpliterator1 = spliterator.trySplit();

        while (stringSpliterator1.tryAdvance(System.out::println));
        System.out.println("=================================");
        while (spliterator.tryAdvance(System.out::println));
    }
}
  • 이렇게 반으로 쪼갤수도 있다. 그리고 각각을 순회시켜서 출력해보면 결과는 다음과 같다.

 

그 외 여러가지 API가 있는데, 이후에 작성할 포스팅에서 Stream API를 배워보면서 자세하게 배워보자!

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

[Java 8] Optional  (0) 2024.11.27
[Java 8] Stream API  (0) 2024.11.27
애노테이션  (6) 2024.10.21
리플렉션  (6) 2024.10.21
ServerSocket으로 HTTP 서버 만들기  (2) 2024.10.18

+ Recent posts