제네릭이 개념중에 가장 코드 적용으로 바로 이어지기 힘든 부분이었다. 필수과제 구현까지 총 3번의 제네릭 활용 리팩토링을 해보았는데 그 과정에서 시행착오가 많았기 때문에 정리해보려고 한다.
문제 1- 전역 validator
validator를 앞으로 도메인 추가될 것을 생각해 전역으로 관리하고 싶었다. Schedule, User, Comment 다른 도메인에서도 거의 동일한 패턴의 검증 로직이 필요했는데 각각의 도메인명에 맞는 메소드를 계속 추가하자니 전역으로 관리하는 이유가 없었다.
❌ 기존 코드
public Schedule findScheduleOrException(Long scheduleId) {
return scheduleRepository.findById(scheduleId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 글입니다."));
}
public void validatePassword(Schedule schedule, String password) {
if (!Objects.equals(schedule.getPassword(), password)) {
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
}
}
- 도메인 종속적 — Schedule만 처리 가능
- User, Comment 등 도메인이 늘어나면 같은 메서드를 복붙해야 함
- 유지보수성 낮음 — 도메인 변경 시 Validator도 수정
- SRP 위반 — 서비스 계층이 검증 로직까지 책임
✅ 제네릭 적용
공용으로 사용하기 위해 엔티티타입을 제네릭으로 받으면 좋겠다고 생각했다. 또한 각 엔티티 repository에 대한 타입도 제네릭으로 처리해야했다.
public <T> T findOrException(JpaRepository<T, Long> repository, Long id) {
return repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 데이터입니다."));
}
개선된 점
- 어떤 엔티티(Schedule, User)든 같은 메서드로 조회 가능
- 호출시 레포지토리만 넘기면 작동
- 코드 재사용성 향상
- 전역 Validator가 도메인에 의존하지 않음
문제 2 - 전역 비밀번호 검증 중복
비밀번호 검증은 수정/삭제(PATCH, DELETE) 시 공통적으로 필요했다.
하지만 도메인마다 password 필드가 달라서 매번 검증 로직을 중복 작성해야 했다.
global 패키지에 다음과 같은 인터페이스를 추가했다.
public interface PasswordValidator {
String getPassword();
}
각 엔티티가 인터페이스를 구현하도록 수정했다.
...
@Entity
public class Schedule extend BaseEntity implements PasswordValidator {
...
private String password;
public String getPassword() { return password; }
}
✅ 제네릭 + 인터페이스 적용
기존 GlobalValidator.java에서는 검증 로직을 다음과 같이 수정했다.
public <T extends PasswordValidator> void validatePassword(T entity, String password) {
if (!Objects.equals(entity.getPassword(), password)) {
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
}
}
- T는 검증 대상 엔티티의 타입
- <T extends PasswordValidator>는 PasswordValidator 인터페이스를 구현한 클래스만 이 메서드에 들어올 수 있다는 의미
→ 즉, User, Schedule, Comment 중 getPassword()를 가진 객체만 통과 가능
개선된 점
- 도메인마다 검증 코드를 작성할 필요가 없어짐
- 컴파일 시점에 안전성 보장 (비밀번호 없는 객체는 통과 불가)
- 공통 규약(PasswordValidator)을 통해 확장성 확보
문제 3 - 로그인 반환 타입 불일치
로그인 성공 시엔 세션이 쿠키에 저장되므로 204 No Content 응답만 주면 충분했다.
하지만 실패 시엔 email과 message를 담은 DTO를 내려주고 싶었는데 이때 반환 타입이 ResponseEntity<Void>와 ResponseEntity<SignInUserResponse>로 충돌했다.
❌ 기존 코드
@PostMapping("/signin")
public ResponseEntity<Void> signIn(@Valid @RequestBody SignInUserRequest request, HttpServletRequest httpRequest) {
try {
userService.signIn(request, httpRequest);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new SignInUserResponse(null, request.getEmail(), e.getMessage()));
}
}
✅ 제네릭 적용
@PostMapping("/signin")
public ResponseEntity<?> signIn(@Valid @RequestBody SignInUserRequest request, HttpServletRequest httpRequest) {
try {
userService.signIn(request, httpRequest);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new SignInUserResponse(null, request.getEmail(), e.getMessage()));
}
}
<?>
- 미지정 와일드 타입
- 제네릭을 사용할 때 타입이 구체적으로 정해지지 않은 경우에 사용
- 특정 타입에 묶이지 않고 어떤 타입의 객체든 올 수 있음
<?>라는 제네릭을 사용해 void와 SignInUserResponse 둘다 받을 수 있게 구현하였다.
배운 점
- 제네릭을 도입하면 코드 재사용성을 극적으로 높일 수 있다.
- 인터페이스를 활용하면 공통 규약(PasswordValidator) 을 강제해 다른 엔티티도 동일한 로직으로 검증 가능하다.
- 리팩토링 후에는 중복이 사라지고 유지보수성이 높아졌지만, 제네릭의 문법적 구조(T, ?, extends)는 여전히 헷갈린다.
- 그래도 왜 필요한가를 생각하며 방향을 찾아 필요한 문법을 공부하며 리팩토링 할 수 있었다.
- 제네릭은 타입 안정성을 보장하는 다형성의 확장이라는 것을 몸소 느꼈다.
이후 전체 리팩토링 진행하며 알게된 점
제네릭 문법자체는 그래도 조금 익숙해졌지만 막상 전체구조를 다시 뜯어보는 시간이 되니 과도한 추상화 문제가 생겼다.
"너 재사용? 전역으로 관리!!"라는 생각에 모든 검증 로직을 하나의 전역검증 클래스로 관리했더니 모든 도메인이 이 클래스에 의존하게되며 어떤 작업을 하든 한번씩은 거쳐가야했다.
일단 내 기존 코드를 보면
@Component
public class GlobalValidator {
PasswordEncoder passwordEncoder = new PasswordEncoder();
public <T> T findOrException(JpaRepository<T, Long> repository, Long id) {
return repository.findById(id)
.orElseThrow(() -> new CustomException(ErrorMessage.NOT_FOUND));
}
public <T extends PasswordValidator> void matchPassword(T entity, String password) {
boolean isMatched = passwordEncoder.matches(password, entity.getPassword());
if (!isMatched) {
throw new CustomException(ErrorMessage.NOT_MATCHED_PASSWORD);
}
}
public String encodePassword(String password) {
return passwordEncoder.encode(password);
}
public <T extends OwnedUser>void forbiddenErrorHandler(T entity, Long userId) {
if (!entity.getUser().getId().equals(userId)) {
throw new CustomException(ErrorMessage.FORBIDDEN);
}
}
}
일단 뭔가 맥락도없달까? 각각 클래스로 뺄까도 생각햇지만 너무 추상화를 해버리니 어떤 도메인을 검증하는지 알 수가 없다. 그래서 방향을 바꿨다. 제네릭은 이번기회에 열심히 파봤으니 가독성과 응집도 기준으로 리팩토링을 진행했다.
findOrException 메소드는 각 도메인 레포지토리의default 메소드로 이동
default User findOrException(Long id) {
return findById(id)
.orElseThrow(()-> new CustomException(ErrorMessage.NOT_FOUND_USER));
}
이렇게 바꾸고 나니 에러메세지도 도메인에 따라 더 명확하게 줄 수 있게 되었다. 또한 쿼리메서드를 사용해 검증 책임도 Repository로 명확하게 이동했다.
matchPassword 메소드는 각 도메인 서비스 메소드로 이동
public void matchPassword(Schedule schedule, String password) {
boolean isMatched = passwordEncoder.matches(password, schedule.getPassword());
if (!isMatched) {
throw new CustomException(ErrorMessage.NOT_MATCHED_PASSWORD);
}
}
비밀번호 검증은 생각해보니 비즈니스 로직에 속해야 할것 같다는 느낌이 들었다. 서비스계층에 두니 비밀번호 매칭 오류 메세지가 나오면 자연스럽게 해당 도메인 서비스 파일만 보면 된다.
최종의최종의최종정리
리팩토링 전
재사용성과 추상화를 극대화했지만, 실질적인 이해도와 유지보수성이 떨어졌다.
리팩토링 후
각 계층이 자기 역할에 충실하고, 코드의 의도와 책임이 눈에 보이게 되었다.
좋은코드는 읽는 사람이 바로 이해할 수 있는 코드라고 튜터님께서 말씀하셨다. 진짜 내가 봐도 미친코드..그자체였음..코드 설명을 하는데지금 이 구조가 잘 보이실까? 설명하는 나도 왜이렇게 안읽히지 싶었다. 또한 이문제도 분리를 제대로 하지 못했기 때문에 발생한 문제라고 생각한다. 모르는 문법(제네릭)을 탐구하는 것도 좋지만 내가 생각했을 때 책임,분리에 대한 생각이 좀 아쉬운편이라고 생각해 좀 더 방향을 찾을 수 있도록 노력해야겠다!
'Dev. > Error.' 카테고리의 다른 글
| [Error] 일정 트러블 슈팅 2- 양방향/단방향 (0) | 2025.11.14 |
|---|---|
| [Error] Port 8080 was already in use (0) | 2025.11.11 |
| [Error] MySQL 버전 충돌 (macOS/Homebrew) (0) | 2025.11.08 |
| [Error] JPA 양방향 연관관계에서 무한 루프문제 해결 (0) | 2025.11.06 |
| [Error] 일정 트러블 슈팅 (1) | 2025.11.05 |