티스토리 뷰
약 2달 만에 쓰는 미션 1 회고.. 기억이 가물가물 하지만 최대한 써보겠다! 미리 쓰자..
🚪레벨 2 첫 미션 시작
레벨 2 첫 미션의 페어는 모코였다. 모코와 나 둘다 스프링을 해본 경험이 있어, 이번 미션은 레벨 1 미션들에 비해서는 꽤나 쉬운 편에 속했다. 그래서 미션보다는 스프링의 내부 동작에 대해 더 깊이 파보는 시간을 가졌다.
레벨 2를 시작하면서 다짐한것은 내가 고민한 부분에 대해 기록하는 것이었다. 레벨 1에서는 이러한 기록을 하지 않아 레벨 1이 다 끝난 이후에 내가 무엇을 배웠는지, 어떤 것들을 했는지 생각이 나질 않았다.
모코가 미션을 진행하며 고민을 적어두는 노션 템플릿을 마침 가지고 있어, 해당 템플릿을 얻어 여기다가 고민들을 적기 시작했다.

1️⃣ [1 ~ 3 단계] 방탈출 예약 추가, 삭제, 조회
Gradle
레벨 2가 되면서, 스프링 부트를 이용해 웹 개발을 시작하게 됐는데, 이때 Gradle에 의존성을 부여하기 시작했다. 예전에는 그냥 넘어갔던 설정을 하나하나 곱씹어 보며 어떤 동작을 하는지 살펴보는 시간을 가졌다.
이 과정에서 gradlew가 무엇인지, gradlew.bat라는 파일은 어떤 파일인지, plugins블럭에는 어떤 것들이 들어가 있는지, repositories블록은 어떤 것을 정의하는지를 알게 되었다. 이전에 무심코 쓰던 것에 대해 이제 이해하고 Gradle 파일을 작성하니 불편했던 마음 한편이 편안해지는 기분이었다.
@RequestBody vs ResponseEntity
컨트롤러에서 객체를 JSON 형태로 넘겨주기 위해서 @RequestBody를 사용하거나, ResponseEntity를 이용해 응답 객체를 만들어줄 수 있다. 이 두 방법중 어느 방법을 사용할지가 고민이 됐는데, 결론부터 말하자면 나는 ResponseEntity를 더 선호한다.
@RequestBody를 사용하면 객체를 리턴하기만 하면 되니, ResponseEntity보다 손쉽게 JSON으로 직렬화 할 수 있다. ResponseEntity는 ResponseEntity를 직접 만들어서 반환해야 하는 번거로움과 메서드 반환 타입을 적어줄 때 길어질 수 있는 문제가 있다.
그럼에도 불구하고 ResponseEntity를 선호하는 이유는 @RequestBody는 응답 상태코드, 응답 헤더를 설정하기 번거롭기 때문이다. 물론 응답 상태코드는 @ResponseStatus 를 이용해 설정해 주고, 응답 헤더 또한 파라미터로 HttpServletResponse 객체를 받아와 설정하면 되긴 하지만, 응답을 만드는 과정이 메서드 여러 줄에 섞여 있어 리턴문만 보고 응답이 어떻게 형성되어 나가는지 한 번에 알아볼 수 없다고 생각했다.
그렇다면 상태코드, 응답 헤더를 커스텀 하는 경우에만 ResponseEntity를 쓰고 그렇지 않은 경우에는 @ResponseBody를 쓰면 안 되냐라고 할 수 있지만, 응답을 만들 때 하나의 방식으로 통일하는 것이 개발할 때 알아야 하는 양을 줄일 수 있다고 생각했다.
예를 들어 우리 팀에 새로운 신입이 왔고, 우리는 이럴때 @ResponseBody를 쓰고 이럴 때는 ResponseEntity를 써야 한다라고 알려주는 것보다는 무조건 ResponseEntity만 써야 한다라고 명시를 해주면 알아야 할 양이 줄어들기 때문에 더 이득이라고 생각했다.
소프트스킬
모코와의 페어 프로그래밍을 마친 뒤, 회고 시간을 가졌다. 모코는 내가 의견을 낼 때 고집을 부리지 않고 상대의 말에 설득당하는 태도가 좋았다고 말했다. 하지만 동시에, 이것이 장점이자 단점일 수 있다고 지적했다. 어쩌면 내가 주관이 뚜렷하지 않아서 쉽게 설득당하는 것일 수도 있다는 것이다.
나도 내 의견이 없다고 생각하진 않지만, 그 의견에 확신이 부족한 건 맞는 것 같다. 그렇다면 내 생각이 옳은지 아닌지는 어떻게 판단해야 할까? 잘못된 생각에 확신을 가지면 그건 고집이 되는 건 아닐까? 그런 고민이 들었다.
또 하나 깨달은 건, 누군가와 토론할 때 나는 귀찮음을 많이 느끼는 편이라, 내 주관이 있어도 말하지 않고 그냥 포기해버린다는 점이다. 이번 페어를 통해 이 부분이 드러났다. 토론이 길어지고 갈등으로 번질 것 같은 상황이 되면, ‘그만하자’는 식으로 피하게 되는데, 이게 오히려 독이 됐다.
그래서 그 이후로는 토론 시간이 오래 걸리더라도 내 생각을 확실하게 말하려고 하고 있다. 그러다 내 의견의 약점이 명확해지면, 그때는 기꺼이 상대에게 설득당하는 방향으로 나아간다.
이렇게 해보니, 내 생각을 표현하는 능력도 확실히 늘었다. 마음속으로만 생각하는 것과 실제로 말로 꺼내는 건 정말 큰 차이가 있다는 걸 느꼈다.
2️⃣ [4 ~ 9 단계] 레이어드 아키텍처 도입 및 시간 관리 기능 추가
Service에서 Controller DTO를 써도 될까
레이어드 아키텍처를 도입하면서, 요청과 응답을 처리하는 Controller와 비즈니스 로직을 담당하는 Service 레이어가 분리되었다.
이 과정에서 Controller가 사용하던 요청 DTO를 Service의 메서드 파라미터로 그대로 전달하게 되었다. 그런데 이 방식은 요청 처리의 책임이 Controller를 넘어 Service까지 전파된 것 아닌가? 라는 의문이 들게 했다.
그렇다면 controller의 요청 DTO와 서비스의 메서드 파라미터 DTO를 나누면 어떨까라는 생각이 들었다. 정리하자면 다음 두 가지 방식 이있는 것이다.
- Controller에서 사용하는 요청 DTO를 그대로 Service에서도 요청 인자로 사용하는 경우
- Controller에서 사용하는 요청 DTO와 Service에서 사용하는 요청 DTO를 분리하는 경우
1번에 대한 나의 생각은 Controller의 요청 DTO를 그대로 Service에서 사용한다면 물론 둘의 값이 같을때가 많기 때문에 재활용을 통해 더욱 빠른 생산성을 보여줄 수 있다는 장점을 가지고 있다고 생각한다.
다만, 요청 API의 명세가 바뀌면 해당 DTO를 사용하고 있는 Service까지 변경해야 하는 상황이 올 수 있기 때문에, Service가 순수하게 관리되지 못한다고 생각한다.
정리하면, API 명세가 변경될때마다 Service 레이어에 변경이 일어나는 단점이 있지만 DTO를 하나만 만들기 때문에 더욱 빠른 개발이 가능하다는 장점이 있다.
그래서 생각해본 방안은 2번 방식이다. 애초에 둘을 따로 관리하면 되지 않을까?
2번 방식의 장단점을 따져 본다면, 컨트롤러와 서비스가 자신의 DTO만 관리를 하니 컨트롤러의 요청 DTO가 변경되어도 서비스 레이어에 영향을 주지 않는다. 즉, 유지보수가 원활해진다. 다만 DTO를 두 번 만들어줘야 하니 관리해야 할 class도 많아지고, 개발 속도도 1번에 비해선 떨어진다.
지금 상황에서 이걸 깊이 고민하는 이유가 controller가 요청으로 받은 DTO와 service에서 사용하는 DTO가 원하는 포맷이 다른 경우가 지금 상황에서는 없기 때문이다. 앞으로도 있을지는 모르겠다.
만약 두 포맷이 다른 상황이 온다면, 두개로 DTO를 분리해야 하는 게 맞다. 그렇다면 두 포맷이 다른 상황은 언제일까?
아직은 그런 상황을 겪어보지 않아 정확히는 모르지만, 추측해본다면 service를 호출하는 controller가 두 개 이상일 때 이런 경우가 생길 수 있을 것 같다.
A controller는 HTTP 로 통신을 해 DTO를 요청으로 받아오지만, B Controller는 HTTP로 통신을 하는 것이 아니라 다른 방법(GRPC)을 통해 요청을 받고 두 컨트롤러 모두 같은 서비스를 호출한다고 하자, 이때 A, B 컨트롤러가 받는 DTO의 포맷은 다를 것이다.
그렇다면 이렇게 포맷이 달라지는 상황이 생길 때만 DTO를 두 개로 분리하면 되지 않을까 생각할 수 있지만 나는 미리 DTO를 두개로 나누는 게 더 좋다고 생각한다.
그 상황이 왔을 때 DTO를 두 개로 분리한다면, 그 자체로 변경에 유연하지 못한 코드가 아닐까? 변경에 유연하도록 미리 두개로 분리하는 게 더 좋지 않을까? 물론 매번 DTO를 두 번 만들어줘야 한다는 단점이 있지만, 이를 통해 얻을 수 있는 장점이 더욱 크다고 생각한다. 복잡한 코드라도 확장에 유연하다면 좋은 코드라고 생각한다.
db에 저장되기 전인 객체와 후인 객체를 나누는 것에 대하여
데이터베이스에 저장되기 전이라면 id 값이 null이고 후라면 id값이 존재한다. id값이 null로 들어갈 수 있는 객체라는 것이 객체를 나누는 신호가 아닐까?라는 생각에 데이터베이스에 저장되기 전인 객체와 데이터베이스에 저장된 후인 객체로 나누는 것이 어떨까 생각되어 나누어 보기로 했다.
public abstract class Reservation {
private String name;
private LocalDate date;
private ReservationTime time;
public Reservation(String name, LocalDate date, ReservationTime time) {
this.name = name;
this.date = date;
this.time = time;
}
public String getName() {
return name;
}
public LocalDate getDate() {
return date;
}
public ReservationTime getTime() {
return time;
}
abstract public Long getId();
}
public class NotInsertedReservation extends Reservation {
public NotInsertedReservation(String name, LocalDate date, ReservationTime time) {
super(name, date, time);
}
@Override
public Long getId() {
throw new IllegalStateException("id값이 존재하지 않습니다.");
}
}
public class InsertedReservation extends Reservation {
private Long id;
public InsertedReservation(Long id, String name, LocalDate date, ReservationTime time) {
super(name, date, time);
this.id = id;
}
@Override
public Long getId() {
return id;
}
}
이렇게 나누니 데이베이스에 너무 의존적인 설계가 된 것 같다. 저렇게 할 경우 단점이 id값이 auto increment가 아닌 경우에는 쓸데없는 짓을 한 것이고, 굳이 id가 null이라는 상태가 싫어 두 개로 나눠서 얻는 이점이 없다는 것이다. 보통 상태 패턴은 각각의 상태가 같은 행위에 대해 다른 행동을 할 때 쓰는데 위와 같이 나눔으로써 다른 행위를 가진 것이 getId 밖에 없다.
즉, 이점이 없고 더 복잡해질 뿐이다.
그렇다면 id가 null인 상태를 난 왜 싫다고 여겼을까? 그 이유부터 찾아가 보자.
- 일단 null값이 들어갈 수 있다면 비즈니스 로직을 처리하던 중에 NPE가 터질 수 있는 위험이 존재한다.
- 그래서 곳곳에 null 체크 로직(if (id!= null))을 작성해야 해 비즈니스 로직이 복잡해진다.
그렇다면 id값을 이용해 비즈니스 로직을 수행하는 곳은 어디일까? 식별자 값으로 우리 비즈니스 로직을 처리하는 건 주로 데이터베이스에서 조회할 때 getId로 꺼내거나, DTO를 만들 때 getId로 값을 가져올 때 사용한다. 즉, 값을 꺼내는 용도로만 사용하고, 이 id값을 이용해 비즈니스 규칙대로 로직을 태우는 일은 없다. 말 그대로 식별자이기 때문이다.
그렇다면 null을 허용하더라도 getter로만 쓰니 괜찮지 않을까? 하지만 id값을 equals, hashcode를 재정의하는데 쓰면 문제가 생길 수 있다. 왜냐면 같은 객체가 둘 다 id가 null일 때 id값으로 equals, hashcode를 재정의 할 경우 두 객체가 동일하다고 판단될 수 있기 때문이다.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Entity)) return false;
Entity that = (Entity) o;
return Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
더불어 id == null 상태일 때 모든 신규 인스턴스의 hashCode()가 동일(보통 0)하게 된다. 이문제를 해결하려면 equals, hashcode를 재정의할 때 id가 null인 경우를 생각해줘야 한다.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Order)) return false;
Order other = (Order) o;
// 영속(영구) 상태: 둘 다 id가 있으면 id로 비교
if (this.id != null && other.id != null) {
return this.id.equals(other.id);
}
// Transient 상태: 참조 동일성(==)으로만 true 허용
return this == other;
}
@Override
public int hashCode() {
// 영속 상태: id 해시코드
if (id != null) {
return id.hashCode();
}
// Transient 상태: JVM 기본(identity) 해시코드
return System.identityHashCode(this);
}
회고
미션을 진행하면서 스쳐 지나가는 고민들을 바로바로 노션에 기록하고, 그 고민들을 해결해 나가는 방향으로 공부하니 미션이 끝난 후에도 막막하지 않았다. 무엇을 해야 할지 헤매기보다는, 이미 쌓아둔 고민들을 하나씩 해결하는 데 집중할 수 있어 좋았다. 앞으로도 이런 방식으로 꾸준히 공부하며 더 깊이 파고들어 가야겠다.