❌ Don't try this at home
호기심에 실험적으로 도전한 내용입니다.
오직 목적한 바에만 몰입하여 구현에만 집중했습니다.
코드 품질은 무시했으니 재미로 봐주시면 감사하겠습니다.
✍️ 제작 동기
레벨2 지하철 노선도 미션에서 Line, Station, Section 이라는 세 도메인을 만났습니다.
각 도메인 별 Dao가 만들어지고 유사한 CRUD로직이 나타나고 있었습니다.
이들을 하나의 CRUD로 추상화해서 중복을 제거해보고 싶었습니다.
JPA를 아주 살짝 사용해본 경험과, 미션의 예시 코드에서 ReflectionUtils를 사용한 내용이 힌트가 되었습니다.
Jackson 라이브러리가 JSON 객체를 직렬화, 역직렬화 하는 과정에서
기본 생성자로 인스턴스를 생성한 뒤, Reflection으로 값을 강제 할당한다는 내용도 힌트가 되었습니다.
💡 컨셉
1. 추상 클래스의 타입 파라미터로 Entity의 자료형 T, Entity의 PK 자료형 ID를 받습니다.
2. Reflection을 이용해 Entity의 필드명과 get 메서드들을 필드에 할당해 보관해둡니다.
3. Entity의 클래스명을 DB의 테이블명으로, 필드명을 DB의 컬럼명으로 사용합니다.
4. Reflection을 이용해 인스턴스를 생성하고 값을 할당하여 CRUD를 구현합니다.
🔎 List<T> findAll();
public List<T> findAll() {
final String sql = "SELECT * FROM " + table;
return jdbcTemplate.query(sql, (rs, rowNum) -> {
final T tEntity;
try {
tEntity = clazz.newInstance();
} catch (InstantiationException | IllegalAccessException e) {
throw new RuntimeException(e);
}
for (String field : fields) {
final Field found = ReflectionUtils.findField(clazz, field);
found.setAccessible(true);
ReflectionUtils.setField(found, tEntity, rs.getObject(field));
}
return tEntity;
});
}
Reflection으로 T의 클래스명에서 Entity를 제거해서 이를 테이블명으로 활용했습니다.
쿼리문은 너무 간단했고, 조회된 결과를 T로 만들어내는 것이 포인트였습니다.
우선 T의 클래스정보의 newInstance() 메서드로 인스턴스를 우선 생성해두었습니다.
이를 위해 기본생성자가 반드시 필요했습니다.
이제 이 인스턴스에 값을 할당하는 것만 남았습니다.
List<String> fields는 필드명들을 리스트로 가지고 있는 컬렉션입니다.
이 필드명을 순회하며 ResultSet의 getObject로 값을 꺼내 필드에 할당합니다.
이렇게 하나의 인스턴스를 Mapping하는 RowMapper를 구현하면,
나머지는 JdbcTemplate에서 처리해줍니다.
🔎 👀 Optional<T> findById(ID id);
public Optional<T> findById(ID id) {
final String sql = "SELECT * FROM " + table + " WHERE id = ?";
try {
final T t = jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
final T tEntity;
try {
tEntity = clazz.newInstance();
} catch (InstantiationException | IllegalAccessException e) {
throw new RuntimeException(e);
}
for (String field : fields) {
final Field found = ReflectionUtils.findField(clazz, field);
found.setAccessible(true);
ReflectionUtils.setField(found, tEntity, rs.getObject(field));
}
return tEntity;
}, id);
return Optional.ofNullable(t);
} catch (EmptyResultDataAccessException e) {
return Optional.empty();
}
}
전체적인 컨셉은 findAll과 매우 유사합니다.
다만 하나의 결과값을 기대하기 때문에 query 가 아닌, queryForObject를 이용했습니다.
추가적으로, 값이 없을 때 발생하는 EmptyResultDataAccessException을 Repository가 직접 처리하지 않고,
이를 응답받는 서비스 레이어에서 확인 후 예외를 던지게 처리하고자 하였습니다.
중복의 제거를 위한 추상화를 진행하는데, 이 내부에서 어떤 예외를 던질지 결정짓는 것은
사용성을 해칠 것이라고 생각했습니다.
추가적으로 Optional을 사용해 값이 없을 수 있음을 명시적으로 메서드 시그니처로 알릴 수 있다는 장점과,
Optional#isEmpty로 간단히 값이 없을 경우에 대한 처리를 할 수 있다는 점도 좋았습니다.
💾 T save(T t);
public T save(T t) {
Map<String, Object> params = getParamsByT(t);
final long generatedId = simpleJdbcInsert.executeAndReturnKey(params).longValue();
final Field idField = ReflectionUtils.findField(clazz, "id");
ReflectionUtils.setField(idField, t, generatedId);
return t;
}
private Map<String, Object> getParamsByT(T t) {
Map<String, Object> params = new HashMap<>();
for (String field : fields) {
final Field found = ReflectionUtils.findField(clazz, field);
found.setAccessible(true);
final Method method = getters.stream()
.filter(getter -> getter.getName().toLowerCase().contains(field))
.findAny()
.orElseThrow(NoSuchElementException::new);
try {
params.put(field, method.invoke(t));
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
}
return params;
}
저장입니다. (JPA와 달리 save와 update를 구분하여 구현했습니다.)
SimpleJdbcInsert는 생성 시점에 DataSource를 매개변수로 받습니다.
추가적으로 메서드 체이닝을 이용해 테이블 명과 Key 컬럼명을 설정할 수 있습니다.
이렇게 함으로써 얻어지는 장점은, INSERT 문을 String으로 직접 작성할 필요 없이,
Map<String, Object> 형태로 각 컬럼에 할당할 값을 전달하기만 하면, INSERT 작업을 처리해준다는 점입니다.
해당 INSERT 작업에 할당된 PK값을 반환해준다는 점도 장점입니다.
따라서 이후 처리되는 INSERT 작업 보다는,
INSERT 작업에 사용되는 Map<String, Object>를 T 로부터 추출해내는 것이 중요했습니다.
T의 클래스 정보 중 메서드들을 꺼내오고, 그 안에 get, has, is가 메서드명으로 포함된 메서드들만 getters로 할당해뒀습니다.
전체 필드들을 돌며, 이러한 getter와 매칭되는 메서드를 찾아서,
key에는 필드명을, value에는 Reflection을 이용해 찾아진 메서드를 invoke 한 값을 담도록 구성했습니다.
따라서 getter 메서드를 구현하는 것이 필수적이었습니다.
📝 long updateById(T t);
public boolean existsById(ID id) {
return findById(id).isPresent();
}
public long updateById(T t) {
if (!existsById(getIdByFromTByReflection(t))) {
return 0;
}
final Map<String, Object> params = getParamsByT(t);
String sql = "UPDATE " + table + " SET ";
sql += fields.stream()
.map(field -> field + " = :" + field)
.collect(Collectors.joining(", "));
sql += " WHERE id = :id";
return namedParameterJdbcTemplate.update(sql, new MapSqlParameterSource(params));
}
private ID getIdByFromTByReflection(T t) {
final Method getIdMethod = getters.stream()
.filter(getter -> "getId".equals(getter.getName()))
.findAny()
.orElseThrow(NoSuchElementException::new);
try {
return (ID) getIdMethod.invoke(t);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
}
전달된 T로부터 id값을 꺼내서, DB에 id에 해당하는 값이 없을 경우,
update된 row가 없다는 의미에서 0을 early return했습니다.
그 이후엔 앞서 save에서 사용한 Entity를 Map<String, Object>로 변환하는 메서드를 활용합니다.
그리고 필드명들을 이용해 NamedParameterJdbcTemplate의 양식에 맞게 sql문을 만들어냈습니다.
만약 JdbcTemplate을 이용해 이를 구현하려 했다면, sql문을 만들어내는 과정이 지금보다 훨씬 지저분했을 것 같습니다.
affectedRow를 반환타입으로 반환하고자 한 이유는,
이후 로직에서 결과를 확인하기에 유용하고 테스트코드를 작성하기 용이할 것 같아서였습니다.
🧹 long deleteById(ID id);
public long deleteById(ID id) {
final String sql = "DELETE FROM " + table + " WHERE id = ?";
return jdbcTemplate.update(sql, id);
}
삭제는 매우 심플했습니다.
클래스 정보에서 클래스명 중 Entity를 제거해서 table명으로 사용하고, id 값만 파라미터로 전달하면 되었습니다.
🙌 결과
@Repository
public class LineRepository extends AbstractRepository<LineEntity, Long> {
public LineRepository(JdbcTemplate jdbcTemplate, DataSource dataSource,
NamedParameterJdbcTemplate namedParameterJdbcTemplate) {
super(jdbcTemplate, dataSource, namedParameterJdbcTemplate);
}
}
@Service
public class SpringLineService implements LineService {
private final LineRepository lineRepository;
public SpringLineService(LineRepository lineRepository) {
this.lineRepository = lineRepository;
}
// ...
@Transactional(readOnly = true)
@Override
public Line findById(Long id) {
final LineEntity lineEntity = lineRepository.findById(id)
.orElseThrow(NoLineFoundException::new);
return new Line(lineEntity.getId(), lineEntity.getName(), lineEntity.getColor());
}
// ...
}
LineRepository는 AbstractRepository를 상속함으로써 모든 CRUD 구현이 완료되었습니다.
SpringLineService에서는 LineRepository를 주입받아 사용합니다.
findById로 Optional<LineEntity>를 반환받고,
비어있을 경우 예외를 발생시킵니다.
NoLineFoundException에서는 생성자 매개변수로 전달받은 id를 이용해
요청했던 id번에 해당하는 Line이 존재하지 않는다는 메시지를 클라이언트에게 반환합니다.
StationRepository, SectionRepository도 마찬가지로 상속으로 모든 구현이 완료되며,
앞으로 다른 도메인을 위한 Repository가 추가로 필요하더라도, 간단히 구현이 완료될 수 있습니다.
😌 소감
Reflection이 스프링 전반에 많이 사용되고 있는 것으로 알고 있는데,
어떻게 사용하는지는 몰라서 궁금했었습니다.
Reflection을 직접 사용해 클래스 정보를 활용해 추상화를 활용해보니
무엇보다 재밌었습니다.
비록 코드 품질은 영 아니지만, 목표했던 것을 달성한 것 같아 기쁘고 즐겁네요.
'우아한테크코스 4기' 카테고리의 다른 글
find vs get (네이밍 컨벤션과 JPA에서의 내부 동작 차이) (2) | 2022.05.24 |
---|---|
🆙 테스트 어려운 부분 끌어올리기 (feat. Spring 체스 게임방) (2) | 2022.05.15 |
@Component 🆚 @Service 🆚 @ Repository (0) | 2022.04.23 |
Spring 의존성 주입 방법 중 생성자 주입을 사용해야 하는 이유 (0) | 2022.04.22 |
Level 1을 정리하는 레벨 인터뷰 후기 (2) | 2022.04.22 |