시험기간이여서 다들 바쁜 만큼 지난 주 학습 내용에 대한 버퍼 기간을 가졌다. 따라서 나는 지난 주에 하지 못했던 PR 피드백 반영을 진행했다.
@ResponseBody
는 응답을 텍스트나 객체 등으로 보낼 때 사용한다. 이는 HTTP 응답 메시지에 담겨 전송되는데, 해당 메시지의 HTTP 헤더 내용(HTTP 응답 코드 등)에는 접근할 수 없다.
HTTP 헤더에 접근하여 응답 메시지를 수정하기 위해서는 ResponseEntity를 반환하면 된다. return ResponseEntity.ok()
와 같이 응답 코드도 수정할 수 있다.
여기서 한 가지 헷갈렸던 부분이 있다. ResponseEntity를 사용할 때는 @ResponseBody
로 수식하지 않아도 된다. @ResponseBody
는 반환 값을 HTTP 응답 메시지의 본문에 주입하여 반환한다는 의미로 사용되지만, ResponseEntity는 반환 객체에 이미 HTTP 헤더와 본문이 주입되어 있기 때문이다.
JOIN이 추가되면서 SELECT의 결과를 매핑받는 클래스를 별도로 생성하게 되었다. 네이밍을 어떻게 할지 고민하다가 이름 그대로 VO(Value Object)에 맞는 클래스라고 생각하여 적용했다. 당시에는 VO의 개념에 대해 명확한 이해 없이 사용했는데, 추가로 찾아보니 내가 생각한 VO와는 거리가 있었다.
내가 생각한 VO는 그저 값을 유지하기만 하는 어떻게 보면 DTO와 흡사하다고 볼 수 있는 클래스였다. 하지만 실제 VO는 불변이기는 했지만 원시 타입들을 묶어 포장하는 실제 도메인 객체에 가까웠다. 결국 네이밍을 어떻게 수정할지 고민하다가 MyBatis의 ResultMap
태그를 활용하기로 했다.
기존: ReservationVo
수정: ReservationResultMap
이번 주에 가장 크게 고민했던 부분은 JdbcTemplate 사용 과정에서 RowMapper나 SqlParameterSource를 생성할 때 BeanProperty 관련 클래스를 사용하는 것이 적절한가? 였다.
public List<Reservation> getAllReservations() {
return jdbcTemplate.query("SELECT * FROM reservation",
new BeanPropertyRowMapper<>(Reservation.class)
);
}
기존에는 SELECT 쿼리의 결과가 도메인 객체와 동일한 필드 목록을 보유했기 때문에 BeanProperty를 사용하여 코드를 간소화할 수 있었다. 하지만 JOIN이 추가되면서 쿼리 결과가 도메인 객체의 필드 목록과 달라지게 되었고, 기존 방법은 사용할 수 없게 되었다.
public List<Reservation> getAllReservations() {
return jdbcTemplate.query("""
SELECT
r.id as reservation_id,
r.name,
r.date,
t.id as time_id,
t.time as time_value
FROM reservation as r inner join time as t on r.time_id = t.id """, new DataClassRowMapper<>(ReservationVo.class))
.stream()
.map(ReservationVoMapper::voToDomain)
.toList();
}
그럼에도 BeanProperty가 코드를 간소화시켜주는 장점을 살리고 싶었던 나는 ResultMap 용도로 사용할 클래스를 만들고 기존 도메인 객체를 매핑시켜주는 Mapper 클래스를 작성했다. 마지막으로 BeanProperty 클래스의 인자로 Mapper를 거친 도메인 객체를 전달했다. (위 코드에서 DataClassRowMapper는 BeanPropertyClassRowMapper와 비슷한 동작을 수행한다.)
하지만 이 부분에 대해 과하게 복잡한 변환 과정이 생긴 것이 아닌지 피드백이 들어왔다. 지금까지는 BeanProperty를 사용하면 코드가 간결해지고, 최대한 사용하는 방향으로 구현했다. 이번에는 관점을 바꿔서 생각해보자. BeanProperty를 사용하기 위해 ResultMap용 클래스를 만들고, 그에 매핑하기 위한 Mapper 클래스까지 생성해야 한다. 코드 리뷰어는 매핑 과정을 이해하기 위해 Mapper를 확인하고 ResultMap까지 확인해야 한다. BeanProperty를 사용하기 위해 불필요한 계층이 생성되는 것이다.
결국 고민 끝에 BeanProperty 사용을 포기하고 ResultSet을 직접 매핑하는 초기 방식으로 돌아갔다. 비록 코드는 조금 길어졌지만 훨씬 직관적이고 불필요한 계층이 제거되어 오히려 더 보기 좋은 코드가 되었다고 생각한다.
최종적으로 작성한 코드는 아래와 같다.
public List<Reservation> getAllReservations() {
return jdbcTemplate.query("""
SELECT
r.id as reservation_id,
r.name,
r.date,
t.id as time_id,
t.time as time_value
FROM reservation as r inner join time as t on r.time_id = t.id """, ((rs, count) -> new Reservation(
rs.getLong("reservation_id"),
rs.getString("name"),
rs.getString("date"),
new Time(rs.getLong("time_id"), rs.getString("time_value"))
)));
}
public Time() {
}
public Time(Long id, String time) {
this.id = id;
this.time = time;
}
위 코드에서 Time 기본 생성자를 왜 정의한 것인지에 대한 리뷰가 들어왔다. 실제로 Time 객체 생성에는 다른 생성자가 사용되었고 코드 상에서 기본 생성자가 호출되는 일은 없었다. 처음에는 내가 깜빡하고 지우지 않은 잉여 코드인줄 알았으나, 해당 부분을 지우고 빌드하니 에러가 발생하는 것을 확인할 수 있었다.
찾아보니 이 문제는 BeanPropertyRowMapper와 연관이 있었다. BeanPropertyRowMapper에서는 기본 생성자를 통해 비어있는 객체를 생성하고, Setter를 통해 객체에 값을 매핑한다. 나는 BeanPropertyRowMapper를 통해 Time 클래스를 매핑하고 있었는데, Time 클래스에 기본 생성자가 정의되어 있지 않으면 문제가 발생하는 것이다.
그래서 IntelliJ에서도 위 사진과 같이 1 usage로 표기되지만 사용처를 찾으려 하면 사용처가 없다는 결과가 나타난다.
이 과정은 리플렉션에 의해 이루어진다. 즉 JVM 메모리에 저장된 클래스 정보를 기준으로 기본 생성자나 Setter 정보를 불러온다. 이는 다시 말하면 접근 제어자에 상관없이 정보를 불러올 수 있다는 뜻이다. 그래서 기본 생성자가 필요하지만 외부에서 임의로 비어있는 객체를 생성하는 경우를 막기 위해서는 기본 생성자의 접근 제어를 private으로 선언할 수 있다.
public Reservation(Long id, String name, String date, Long timeId, String timeValue) {
this.id = id;
this.name = name;
this.date = date;
this.time = new Time(timeId, timeValue);
}
Reservation 객체를 생성할 때 하위 객체(Time) 정보를 생성자에서 전부 전달받은 뒤, 생성자에서 하위 객체를 직접 생성하는 방법으로 구현했다. 이 부분에 대해 외부에서 Time 객체를 생성 후 생성자로 주입해주는 방법은 어떤지 피드백이 들어왔다.
최초에 생성자를 구현할 때는 외부에서 Reservation 객체가 내부에 Time 객체를 유지한다는 사실을 알게 되어 캡슐화를 깰 우려가 있다고 판단했다. 하지만 이렇게 생성자를 작성할 경우 Reservation이 Time의 필드에 대해 의존적이게 되고, 강한 결합을 유지하게 된다.
public Reservation(Long id, String name, String date, Time time) {
this.id = id;
this.name = name;
this.date = date;
this.time = time;
}
결국 위와 같이 외부에서 하위 객체를 생성하여 주입받는 방향으로 생성자를 수정하였다.
위에서 작성한 Reservation 생성자 모습은 그 자체로 완전하지 않다고 볼 수 있다. 객체 생성 시 전달받고자 하는 매개변수 목록이 다른 경우가 존재할 수 있다.
이에 대한 해결 방안으로 아래와 같은 대안이 제시되었다.
1번 방식은 각 생성자에 대한 아이덴티티(특정 생성자를 사용해야만 하는 이유)가 부족하고 생성자에서 특별한 로직을 수행하는 모습이 적절하지 않다고 판단했다.
2번 방식은 생성자에 이름을 지어줌으로써 사용자가 어떤 생성 방법을 사용할지 이유를 명확히 할 수 있었고, 외부에 내부 생성자 로직을 숨기면서 생성자에서 필요한 로직을 별도의 메서드에서 수행하는 모습이 적절하다고 판단했다.
최종적으로 여러 생성자를 정의해야 하는 경우에는 내부적으로 private한 주 생성자를 하나 유지하고 정적 팩토리 메서드에서 해당 생성자를 호출하는 형식으로 진행하는 것이 적절하다고 판단했다.
이번 스터디를 진행하면서 도메인에 대해 Setter를 무분별하게 사용한 감이 있다. 지양하는 것이 좋다는 것은 알고 있었지만 꺼림칙함을 안고 Setter를 사용했던 것 같다. Setter를 이용한 부분을 살펴보면 대부분이 특정 라이브러리(BeanProperty)를 사용하기 위함이었다. 하지만 해당 라이브러리는 리플렉션을 통해 Setter 정보를 가져와 사용한다. 즉 접근 제어를 private으로 설정해도 문제가 없다는 것이다. 따라서 Setter를 private하게 선언함으로써 객체의 강건성을 보장할 수 있다.
그럼에도 Setter를 지양해야 하는 이유 중 하나는 객체의 불변성이 깨진다는 점이다. 필드의 final
수식이 불가능해진다고도 할 수 있다. 이는 Setter에서 새로운 객체를 생성하여 반환하는 방법으로 해결할 수 있다.
public Reservation withTimeId(Long timeId) {
return new Reservation(this.id, timeId);
}
시험기간이기에 바빠서 많은 활동을 하지는 못했지만 PR 피드백을 반영하는 것만으로도 큰 도움이 되었던 한 주였다.
추가적으로 이번 주에는 초록 스터디를 지원해주는 원티드 사무실에 모여서 대면 스터디를 진행했다. 뭔가 열심히 해서 이렇게 큰 기업에 들어가고 싶다는 의욕도 생겼던 것 같고 좋았다.