[인용 및 참고 출처]
1. 구글 검색: Oracle, "record class docs", Record Classes, (2024.12.23)
1. 프로젝트 진행 상황 및 계획
🥇 CRUD 구현하기: 팔로잉 요청 (완료)
🥈 CRUD 구현하기: 팔로잉 목록 조회 (완료)
🥉 CRUD 구현하기: 팔로잉 상태 변경 (완료)
4️⃣ CRUD 리팩토링(refactoring) (진행 중, 24.12.24 완료 목표)
5️⃣ 오늘 느낀 점과 고민을 TIL에 기록하기 (완료)
6️⃣ 24.12.20 TIL 내용 보충하기 (진행 중, 24.12.26 완료 목표)
7️⃣ 일정 관리 앱 Develop 밀린 트러블슈팅(troubleshooting) 작성 끝내기 (보류, 24.12.29 완료 목표)
8️⃣ 일정 관리 앱 만들기 KPT 회고하기 (보류, 24.12.29 완료 목표)
9️⃣ 일정 관리 앱 Develop KPT 회고하기 (보류, 24.12.29 완료 목표)
🔟 뉴스피드 프로젝트 중 발생한 트러블슈팅 초안 수정하기 (진행 중, 24.12.24 완료 목표)
2. 고민
Q1. 회원 한 명이 프로필(profile)을 여러 개 생성할 수 있는 상황에서 프로필 삭제 API를 구현할까 말까?
A1. 프로필 삭제 API를 추가로 구현하기로 했다. 처음에는 프로필 삭제 기능이 회원 탈퇴와 동일하다고 여겼으나, 이렇게 생각하면 프로필을 여러 개 생성하는 의미가 사라졌다. 회원이 프로필을 모두 지우고 새로운 프로필을 생성할 수도 있으므로 프로필 삭제 API를 추가로 구현하기로 했다.
Q2. 뉴스피드(newsfeed)와 팔로잉(following)은 회원과 프로필 중 무엇을 참조해야 하지?
A2. 전체 흐름을 생각하면 프로필을 참조해야 했다. 한 회원이 여러 프로필을 생성하고, 그 프로필에 닉네임 필드(field)가 있으므로 프로필을 참조해야 했다. 또한 프로필을 수정하거나 삭제할 때 추가로 비밀번호를 입력하는 기능은 구현하지 않았고, 필터(filter)와 JWT(JSON Web Token)로 우선 검증이 이루어지므로 회원 참조는 나중에 비밀번호 추가 입력 기능을 구현할 여력이 된다면 반영하기로 했다. 팔로잉 또한 마찬가지였다. 한 회원은 다른 회원이 작성한 프로필을 보고 팔로잉을 요청할 테니 프로필을 참조해야 했다. 다시 말해 외래키(Foreign Key)가 회원의 식별자가 아니라 프로필의 식별자여야 여러 프로필을 생성하는 의미가 있었다.
Q3. 사용자가 식별자 1에 해당하는 프로필을 삭제했다고 가정하자. 이때, 사용자가 식별자 1에 해당하는 프로필을 두 번 삭제하겠다고 다시 딜리트(DELETE) 메서드(method)를 호출한다면 예외 처리를 해야 할까, 아니면 200 OK 메시지를 응답으로 전달해 줘야 할까?
A3. 이 부분은 결국 엄격한 애플리케이션(application)으로 방향을 잡을지 말지에 달렸고, 우리는 여러 가지를 고민한 끝에 예외 처리를 하기로 했다. 첫 번째 이유로는, 사용자에게 '이미 삭제된 프로필입니다' 같은 메시지를 보내서 사용자가 필요 없는 메서드를 호출하지 않도록 하고 싶었다. 두 번째로는 이미 삭제된 프로필을 또 삭제하겠다고 사용자가 요청했을 때 '200 OK' 메시지를 응답으로 보내면, 사용자는 '삭제된 프로필은 다시 삭제할 필요가 없다'는 점을 명확하게 알기 어려웠다. 정리하자면, '이미 삭제된 프로필을 다시 삭제하겠다고 요청하는 행위'를 우리는 '올바르지 않은 요청'으로 판단하여 예외 처리를 하기로 했다.
3. 리팩토링(Refactoring) 목록
① Request DTO 및 Response DTO는 불변 객체이므로 Record 클래스(class)로 맞추기
package com.spring.instafeed.user.dto.request;
import lombok.Getter;
@Getter
public class SignUpUserRequestDto {
// 속성
private final String name;
private final String email;
private final String password;
// 생성자
public SignUpUserRequestDto(
String name,
String email,
String password
) {
this.name = name;
this.email =email;
this.password = password;
}
}
package com.spring.instafeed.user.dto.request;
public record SignUpUserRequestDto(
String name,
String email,
String password
) {
}
② 세터(Setter)는 기억 속에서 존재 자체를 지우기! 세터 대신 적절한 메서드(method) 활용하기
③ JPQL (Java Persistence Query Language) 대신 성능과 유지보수 측면에서 유리한 JPA의 쿼리 메서드(query methods) 활용하기
④ default 접근 제어자는 자바(Java) 8 이상에서만 사용할 수 있으므로, 호환성을 고려해 가능한 한 사용 피하기
package com.spring.instafeed.user.repository;
import com.spring.instafeed.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.http.HttpStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
@Query(
"SELECT u FROM User u " +
"WHERE u.id = :id " +
"AND u.isDeleted IS NULL"
)
Optional<User> findByIdExceptDeleted(Long id);
default User findByIdOrElseThrow(Long id) {
return findByIdExceptDeleted(id).orElseThrow(
() -> new ResponseStatusException(
HttpStatus.NOT_FOUND,
"입력된 id가 존재하지 않습니다."
)
);
}
}
package com.spring.instafeed.user.repository;
import com.spring.instafeed.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByIdAndDeletedAtIsNull(Long id);
}
⑤ 레포지토리 레이어(Repository Layer)는 데이터베이스(database)와 소통하는 데에 집중하도록 예외 처리 같은 부분은 서비스 레이어(Service Layer)로 옮기기. 일명 비즈니스 로직(business logic)은 서비스 레이어로 옮기기
package com.spring.instafeed.user.service;
import com.spring.instafeed.user.dto.response.UserResponseDto;
import com.spring.instafeed.user.entity.User;
import com.spring.instafeed.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserServiceImpl implements UserService {
// 속성
private final UserRepository userRepository;
@Override
public UserResponseDto findById(Long id) {
User foundUser = userRepository.findByIdAndDeletedAtIsNull(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND,
"입력된 id가 존재하지 않습니다."
)
);
return UserResponseDto.toDto(foundUser);
}
}
⑥ 엔티티(Entity)의 생성자 중 쓰이지 않는 생성자의 접근 제어자는 public이 아닌 protected로 맞추기
⑦ 데이터베이스 간 호환성을 고려하여 VARCHAR 대신 length 사용하기
package com.spring.instafeed.user.entity;
import com.spring.instafeed.base.BaseEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.hibernate.annotations.Comment;
@Getter
@Entity
@Table(name = "users")
@AllArgsConstructor
public class User extends BaseEntity {
@Comment("사용자 식별자")
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(columnDefinition = "BIGINT")
private Long id;
@Comment("사용자 이름")
@Column(name = "name", nullable = false, length = 16)
private String name;
// [수정 전] columnDefinition = "VARCHAR(16)"
// [수정 후] length = 16
@Comment("사용자 이메일")
@Column(name = "email", nullable = false, unique = true, length = 255)
private String email;
// [수정 전] columnDefinition = "VARCHAR(255)"
// [수정 후] length = 255
@Comment("사용자 비밀번호")
@Column(name = "password", nullable = false, length = 255)
private String password;
// [수정 전] columnDefinition = "VARCHAR(255)"
// [수정 후] length = 255
protected User() {
}
// [수정 전] public User() {}
public User(
String name,
String email,
String password
) {
this.name = name;
this.email = email;
this.password = password;
}
}
4. 레코드 클래스(Record Class)란?
▷ 레코드 클래스의 선언은 헤더에서 해당 내용의 설명을 지정하며 이에 따라 클래스의 속성에 해당하는 필드(field) 값을 읽어오는 메서드인 게터(getter)와 같은 접근자, 생성자, equals(), hashCode(), toString() 메서드가 자동으로 생성된다. 레코드 클래스의 필드는 클래스가 단순한 '데이터 운반자'로 사용되도록 설계되었기 때문에 final로 설정된다.
▶ A record declaration specifies in a header a description of its contents; the appropriate accessors, constructor, equals, hashCode, and toString methods are created automatically. A record's fields are final because the class is intended to serve as a simple "data carrier".
여기서 말하는 헤더(header)는 사진 속 코드 전체를 가리킨다. 각 부분은 다음과 같이 구성되었다. 즉, 헤더란 레코드를 정의하는 부분 전체라고 할 수 있다.
① public: 접근 제어자
② record: 레코드 클래스를 정의하는 부분
③ SignUpUserRequestDto: 레코드 클래스의 이름
④ (String name, String email, String password): 레코드 클래스의 필드, 즉 속성 목록
5. 회고
각자 맡은 기능을 구현하면서 오류가 발생하기도 하고 깃허브(GitHub)에서 각자 작업한 결과물을 합치면서 충돌도 발생했지만, 생각보다 협업은 잘 이루어지는 듯하다. 오늘 아침에 예정된 데일리 스크럼(Daily Scrum) 시간에는 팀 전체에 아래와 같이 얘기하며 운을 띄웠다.
"혹시라도 작업하면서 속도가 느려서 다른 팀원이 기다리지 않을까, 하는 고민은 넣어두시면 좋겠습니다. 저희는 어차피 그동안 발표 자료 제작이라든지 검토 및 리팩토링처럼 다른 작업을 할 테니까요. API 명세서도 작성해야 하고요. 팀에서 해야 할 일은 많습니다. 그러니 '내 속도가 느려서 팀에 민폐가 되면 어떡하지?'란 생각은 안 하시면 좋겠습니다."
이때 입 밖으로 꺼내진 않았으나, 속도가 느리다는 이유로 팀원에게 맡긴 일을 다른 팀원에게 넘기거나 내가 처리할 생각은 전혀 없었다. 그렇게 하면 결과물이야 빨리 낼 수 있겠지만, 팀원이 배울 기회를 빼앗는 행위나 마찬가지니까. 사실 나도 생각이 많아서 코드 작성하는 속도가 아주 느린 편이기도 하고.
오늘 각자 작업한 결과물을 합친 프로젝트를 쭉 읽고 검토하면서 세 가지를 깨달았다.
첫째, API 명세서에 Request Body 형식과 Response Body 형식을 기능 구현 전에 무조건 작성하자.
둘째, 변환 방식이나 패턴(Pattern)을 팀에서 정한 다음 작업에 돌입하자. 예를 들어 빌더(Builder) 패턴을 전부 적용할지 말지 꼭 미리 결정하자.
셋째, 변수가 여러 개일 때 소괄호를 띄어 쓸지 말지 또한 작업 전에 반드시 정하자. 코드 읽을 사람을 생각해서 주석도 열심히 달자.
내일 목표는 리팩토링 완료와, 개발한 기능이 모두 제대로 작동하는지 점검하기이다. 슬슬 발표자도 정하고 발표 자료도 제작해야 한다. 오늘은 기능을 전부 구현하느라 운동을 점심때 10분 정도밖에 못 했으므로, 내일은 좀 더 건강한 집중력을 발휘해야겠다.
'끝을 보는 용기' 카테고리의 다른 글
Day 080 - 뉴스피드 프로젝트 75%, 좋은 팀장이란 어떤 사람일까? (0) | 2024.12.25 |
---|---|
Day 079 - 뉴스피드 프로젝트 65%, 팀장에게 인후염은 숙명이다. (0) | 2024.12.24 |
Day 077 - 뉴스피드 프로젝트 15%, Octotree 사용하기, 깃허브(GitHub)에서 난생처음 상어 뱃지를 얻다. (0) | 2024.12.22 |
Day 076 - 과감하게 휴식 (0) | 2024.12.21 |
Day 075 - 뉴스피드 프로젝트 10%, ERD 및 API 명세서 작성, 일정 계획 및 업무 분담 완료 (25.01.01 수정 예정) (0) | 2024.12.20 |