우테코 5기

[레벨 2 미션] 지하철 미션 학습 기록(1)

teo_99 2023. 5. 29. 19:39

CRUD와 도메인 엔티티

이번 지하철 미션에서는 Line이라는 도메인 엔티티가 존재했습니다. 이 Line이라는 도메인 엔티티는 '노선'이라는 개념을 담습니다. 그리고 기본적으로 제공된 Line 객체는 다음과 같았는데요.

public class Line {

    private Long id;
    private String name;
    private String color;
    ...
}

 

이후 저는 '노선은 어느 역이 포함되어 있는지 알고 있어야 한다'는 요구사항에 따라 다음과 같이 sections라는 프로퍼티까지 추가했습니다.

public class Line {

    private final Long id;
    private final String name;
    private final String color;
    private final List<Section> sections;
    ...
}

그러자 문제가 발생했습니다. 노선 정보를 추가/삭제/조회/수정 할 때 사용하는 도메인 엔티티도 Line이고, 실제 도메인 로직을 수행해 노선에 역을 추가, 삭제하는 로직도 Line이 수행했습니다. 이게 왜 문제가 되냐면, CRUD 시점에서 Line 객체는 불완전하다는 이야기이기 때문입니다.

 

즉, CRUD 시점에서 Line 객체를 사용하지만, sections 프로퍼티는 사용되지 않습니다. 그러니까 하나의 도메인 엔티티 안에 두 가지 개념(도메인 로직 수행용 객체로서의 Line, CRUD용으로서의 Line)이 존재했습니다. 그러자 이런 생각이 들었습니다. 'CRUD는 도메인 엔티티를 통해 수행하면 안되는 것일까?' 하지만, 그렇다고 해서 CRUD 전담 객체를 만들어 사용하게 되면 도메인 엔티티에 존재하는 비즈니스 규칙을 적용시킬 수 없습니다. 

 

 그래서 제가 내린 결론은 다음과 같습니다. '한 도메인 엔티티가 두 개의 개념, 즉 두 개의 라이프사이클을 모두 담당하니 문제가 생기는 것이다. 따라서 객체를 분리해야 한다.' 그리고 Line에서 LineProperty 개념을 분리했습니다.

 

public class Line {

    private final LineProperty lineProperty;
    private final List<Section> sections;
    ...
}
public class LineProperty {

    private Long id;
    private String name;
    private String color;
    ...
}

이렇게 분리하고 나니, Line 도메인 엔티티는 더 이상 불완전하게 사용되는 일이 없었습니다. CRUD 시에는 LineProperty를 사용하면 되고, 도메인 로직을 수행하고자 할 때에는 Line 객체를 사용하면 됩니다. 

 

사실 위처럼 분리를 안해도 코드를 작성할 수는 있긴 합니다. 하지만 저는 도메인 객체의 불완전함이 마음에 들지 않았고, 객체 분리를 진행했습니다. 이런 분리는 데이터 관점의 분리라고 생각됩니다. CRUD라는 개념이 없었다면 위처럼 분리되었지도 않았겠죠. 하지만 이런 부분은 관리의 용이성을 위해서, 적어도 웹 개발을 하고 있다면 도메인에서 어느정도 내어줘야 하는 부분이라고 생각합니다.

 

외래키 제약조건

여태까지 미션을 진행하면서 외래키를 적용해본 적은 없었는데요, 대부분은 비즈니스 로직에서 대응이 가능했기 때문입니다. 그리고 외래키를 적용하게 되면 테스트가 어렵고 개발할 때 신경을 계속 써줘야 하는 문제점 때문에 꺼려지는 것도 있었습니다.

 

하지만 이번 미션을 진행하면서 외래키를 사용해보는게 어떻겠냐는 리뷰어님의 말씀이 있었고 갑자기 외래키 사용을 언제 해야 할지에 대한 기준이 궁금해져 찾아보기 시작했습니다.

 

이것저것 찾아본 결과 제가 알아낸 정보는 바로 '외래키는 실무에서 잘 사용되지 않는다' 라는 것이었습니다. 외래키는 어디까지나 제약조건입니다. 물론 데이터 정합성을 보장할 수 있다는 측면에서 매우 효율적이지만, 그만큼 유연하지가 않습니다. 초기에 외래키 설정을 잘못하는 경우 추후 테이블 수정의 어려움으로 이어질수도 있고, 수동으로 쿼리를 쏴야 하는 경우도 외래키 때문에 문제가 생길 수 있다고 합니다. 그리고 리뷰어님 말씀으로는 데드락 위험성과 마이그레이션의 불편함도 존재한다고 합니다.

 

하지만 '외래키를 사용해야 한다!' 라는 입장도 존재합니다. 그 입장에 대해서는 아래 아티클에 정말 자세하게 설명이 나와 있습니다.

https://engineering-skcc.github.io/oracle%20tuning/foreign_key_%EC%97%86%EC%9D%B4_%EA%B5%AC%EC%B6%95%ED%95%98%EB%8A%94_DB/

 

Foreign Key 없이 구축하는 관계형 데이터베이스 시스템에 대한 생각

Foreign Key (Referential Integrity) 없이 구축하는 관계형 데이터베이스 시스템에 대한 생각

engineering-skcc.github.io

 

외래키를 부여할 것인가, 말 것인가, 그리고 얼만큼 부여할 것인가는 비즈니스에 따라 판단해야 할 것 같습니다. 예를 들어, 핀테크 같은 도메인이라면 데이터 자체가 중요하기 때문에 외래키를 적극 검토해볼 수 있을것입니다. 하지만 유연성이 최고 가치로 꼽혀야 하는 도메인에서는 외래키가 잘 사용되지 않겠죠. 다만 지금은 연습을 하는 단계이니 외래키 사용을 완전히 배제해서는 안될 것 같습니다. 

 

Wrapper 타입 사용 근거

리뷰어님이 데이터베이스 엔티티를 보고 다음과 같이 질문해주셨습니다.

 이에 대한 제 답변은 다음과 같았는데요,

  1. 오토박싱, 오토언박싱으로 인한 비용은 그렇게 크지 않다고 생각한다. 반복적으로 값을 변경해야 하는 상황이라면 고려해야 맞겠으나, 데이터베이스 엔티티는 이러한 고민이 불필요하다고 생각된다. 
  2. Wrapper 타입을 사용하면 Nullable한 필드를 나타낼 수 있다. 현재 데이터베이스 스키마는 not-null 제약조건이 부여가 되어 있지만, 굳이 프로덕션 코드에서 스키마에 대한 가정을 할 필요는 없다고 생각한다.

이러한 의견에 대해 리뷰어님도 'Entity는 프로덕션 코드여도 DB 스키마를 기반으로 만들어진 객체이므로 DB 스키마를 고려하여 작성하는 것이 맞다고 판단되나, non-nullable을 nullable로 변경할 수도 있기도 해서 저도 Wrapper를 쓰는 편이긴 합니다.' 라고 답변해주셨습니다.

 

확실히 리뷰어님이 말씀해주신대로 엔티티는 데이터베이스 테이블에 대한 추상화이므로 스키마를 고려하는 게 맞지만, 유연성을 위해 Wrapper 타입을 사용하는 게 나을 것 같다는 생각이 듭니다. 다만 앞서 이야기했던 오토박싱, 오토언박싱 문제가 발생하는 특수한 경우를 조심해야 할 것 같긴 합니다.

 

 쿼리 개선하기

최근에는 프로덕션 코드에만 집중하느라, 쿼리 자체에 대해서는 집중하지 못했던 것 같습니다. 그러다보니 성능 문제가 우려되는 쿼리를 많이 작성하게 되었는데요. 조인을 수행하지 않고 코드를 짜다보니 N개의 정보에 대해 N번 또다시 쿼리를 보내야 하는 상황이 나왔고 리뷰어님이 이를 지적해주셨습니다. 이후 어플리케이션 단에서 조인을 진행하는 방식으로 문제를 해결했습니다.

 

그리고 여러 리스트 값에 해당하는 컬럼을 찾아야 하는 경우, 예를 들어 StationId가 [1,2,3,5]인 Station 컬럼을 찾고 싶다면 "SELECT ... WHERE ... IN" 쿼리를 활용해보라고 리뷰어님이 추천해주셨고, 다음과 같이 쿼리를 개선할 수 있었습니다.

 public Map<Long, String> selectKeyValueSetWhereIdIn(List<Long> ids) {
        String inSql = String.join(",", Collections.nCopies(ids.size(), "?"));
        String sql = String.format("select id, name from station where id in (%s)", inSql);

        List<Map.Entry<Long, String>> nameIdKeyValue = jdbcTemplate.query(sql,
                (rs, cn) -> Map.entry(rs.getLong("id"), rs.getString("name")),
                ids.toArray());

        return nameIdKeyValue.stream()
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
    }

 

 

DAO, Repository 관계 정립

저는 DAO에 실제 쿼리를 넣고(테이블에 대한 추상화로서 사용합니다), Repository는 도메인 객체 저장소로 사용합니다. 그리고 Repository의 인터페이스를 도메인 패키지 내에 위치시켜 의존성을 역전시키는데요. 이런 부분에 대해 리뷰어님이 칭찬해주셔서 좋았습니다.

 

무조건적인 DIP, 무조건적인 Repository나 DAO 사용은 지양해야겠지만 리뷰어님이 말씀해주신대로 복잡한 요구사항이 생긴다면 적용해볼 법한 아키텍처라고 생각합니다. 

 

예외 전략

리뷰어님이 다음과 같은 질문을 주셨습니다.

그리고 저는 다음과 같이 답변했습니다!

개인적으로 이 부분에 대해서는 확고한 입장을 가지고 있었습니다. 계층 간 격리를 깨버릴 수 있기 때문에 예외까지도 관리의 범주에 포함시켜야 한다는 입장이었는데, 리뷰어님이 다음과 같이 설명해주셨습니다. 

요약하자면 각 계층마다 변환시켜주는 불편함이 있고, 비용이 드는 일이기 때문에 항상 trade-off를 고려해봐야 한다는 것입니다. 그리고 실제 현업에서 사용하시는 코드를 보여주셨는데, 리뷰어님 팀에서는 계층 간 격리를 위해 예외까지도 따리 관리하고 있다고 합니다! 무조건 계층 격리가 맞다, 아니다가 아니라 팀과 협의해 결정할 문제라고 생각이 되는 계기였습니다.