빨랐죠 GitHub https://github.com/gyehyun-bak/ppalatjyo
프로젝트 기획을 간단히 마치고 본격적으로 개발에 들어갑니다. 사실 디자인을 이미 마쳐버렸지만, 그래도 여태까지 해왔던 디자인 우선의 개발 방식에서 벗어나고자 서버 쪽 개발을 먼저 (도메인 중심으로) 해보려고 합니다. 또한 이번 프로젝트에서는 개발 초기부터 TDD를 적용해보려고 합니다. 해당 개발 과정을 기록해봅니다.
최근 켄트 벡의 <테스트 주도 개발> 책을 가지고서 스터디를 진행했습니다. 지난 여러 프로젝트를 진행하고, 공부를 하는 과정에 테스트에 대한 궁금증이 많아졌기 때문입니다. 책은 여러 아이디어를 주었고, 아직 궁금증으로 남아있는 부분도 있지만, TDD를 해봐야겠다는 생각을 더 강하게 했습니다.
Test-driven development (TDD) is a way of writing code that involves writing an automated unit-level test case that fails, then writing just enough code to make the test pass, then refactoring both the test code and the production code, then repeating with another new test case.
출처: 위키피디아
테스트 주도 개발(Test-Driven Development, TDD)이란 개발 방법론의 일종으로, 실패하는 단위 테스트를 작성하고(Red), 빠르게 해당 테스트가 패스하도록 최소한의 구현을 한 다음(Green), 리펙토링하는 과정(Refactor)을 반복하는 개발 방법을 말합니다.
위 사이클 각 부분에 이름을 붙여뒀는데 그것이 Red-Green-Refactor 입니다. 중요한 것은 이 주기를 최대한 짧게 가져가는 것이라고 합니다(이 "짧다"의 기준이 아무래도 의견 차이가 많은 것 같습니다).
TDD는 등장한지 오랜 기간이 지났음에도 그 효용성에 있어서 아직까지도 개발자들 사이에서 의견이 갈리는 핫한 주제입니다. 하지만 의견이 갈림에도 오랜 기간 살아남고, 매번 다시 언급되는 데는 이유가 있다고 생각합니다.
모든 개발 방법론/패러다임이 그렇지만 모든 상황에 정답인 "만능 도구"는 없다고 생각합니다. 상황에 맞게 유동적으로 선택하며, 그럼에도 언제든 돌아올 수 있는 중심축을 잡는 일이 중요한 것 같습니다.
(이런 모습을 보면 개발자들이 마치, 어떤 행동이 전통에 맞는지 해석을 가지고 매번 다투는 드워프들과 닮지 않았나 하는 생각이 듭니다ㅋㅋㅋ. 또, 그렇게 뭐가 맞는지 다투는 과정에 돈독해지고 더 나은 선택을 할 수 있게 되는거 아닐까요?ㅎㅎ)
이런 논란에도 불구하고 이번 프로젝트에서 TDD 도입을 선택한 이유는 다음과 같습니다.테스트를 먼저 작성하는 TDD의 특성을 test-first라고 부릅니다.
마지막으로 했던 가장 큰 프로젝트에서는 기능 코드를 먼저 작성하고, 후에 필요한 테스트 코드를 작성하는, 이른바 test-after 방식으로 개발을 했습니다. 사실, 말이 test-after지 테스트에 대한 명확한 지침 없이 개발을 했으며, 이로 인해 겪었던 문제점은 다음과 같습니다.
🙏 제가 똑바로 못한 거지 테스트를 뒤에 하는 방식 자체가 아래 문제점을 가지고 있는게 아님을 미리 알립니다.
빨리 빨리 기능을 배포하려다 보니, 테스트 코드를 나중에 써야지 하고, 바로 통합 테스트(Postman, 실제 화면과 연동)를 진행한 후 프로덕션에 머지하는 과정을 반복했습니다. 그러다 보니 뒤늦게 발견하는 버그가 늘어나고, 늘어난 버그에 먼저 대응하다 보니, 기능 구현이 늦어지고, 또 늦어진 기능 일정을 따라잡으려다 보니 작성 못한 테스트는 더 쌓여만 갔습니다. 결국 어디서부터 테스트가 안 써졌는지 파악이 안 되는 지경까지 갔습니다.
코드가 적을 때는, 어디까지 개발했는지 다 알고 있었고, 뭔가 잘못되도 전체 코드 베이스가 머리 속에 있었으니 바로 바로 대응이 가능했습니다. 어떤 부분을 수정했을 때 어느 부분이 영향을 받을지 예상할 수 있었습니다. 하지만, 기능이 늘어나자 그게 불가능해졌고, 자연스럽게 버그가 발견되면 대응하는 방식의 개발을 하게 되었습니다. 그게 점점 쌓여가다 보니, 새 기능을 추가해도 잘 동작하도록 하는데까지의 시간이 기하급수적으로 늘어났습니다. 개발이 아니라 젠가를 하는 기분이랄까...?
천재적인 기억력을 가지고 있다면 좋겠지만, 왠만한 코드는 한 달(한 달도 긴데?)이 지나면 내가 썼어도 어색해지기 시작합니다. 남이 쓴 코드는 더 심합니다. 기능 추가 시 전체 코드가 어떻게 돌아가는지 알아야 되는데, 테스트가 없으니 이걸 API 요청을 실제로 때려보고, 모든 계층 코드를 거슬러 올라가보면서 디버깅을 해야했습니다. 그렇게 이러라고 써둔 코드구나 이해하고 나야 기능을 추가할 수 있었으니, 자연스럽게 개발 시간도 길어졌고, 간단한 기능 추가나 변경에도 부담이 커져갔습니다. 더불어 협업에서도, 다른 사람의 코드 의도를 직접 물어보지 않으면 오해의 여지가 쉽게 생기며, 제대로 작동하는 여부를 스크린샷 등으로 공유를 해야하니 번거러움이 이만저만이 아니었습니다.
위와 같은 경험을 하면서 테스트 코드의 필요성을 절실히 느꼈고, TDD 혹은 테스트를 먼저 작성하는 것의 장점이 매력적으로 느껴져 도입을 결정하게 되었습니다.
"TDD를 하겠다는 건 알겠는데, 그게 정확히 뭘 하자는건가?"를 정확하게 정하고 가면 좋을 것 같습니다. TDD는 방법'론'이고 정답이 없기 때문에, 다양한 의견들이 있으며, 구체적인 사항은 개발자가 정해야 합니다. 따라서 해당 프로젝트에서는 아래와 같은 사항을 따르려고 합니다.
TDD로 작성하는 테스트의 목표는 다음과 같습니다:
최소 개발: 핵심 비즈니스 로직을 중심으로 테스트를 작성하므로써, 현재 시스템에 불필요한 코드를 작성하지 않도록 합니다. 어디 쓰는지도 모르는 코드가 많아지면 나중에 유지보수가 힘들어지기 때문입니다.
문서화: 전체 시스템에 대한 명확한 문서를 제공합니다. 테스트는 핵심 로직에 대한 문서로써 기능할 수 있습니다. 제대로 된 문서화는 협업과 유지보수에 큰 도움을 줍니다.
체크포인트: 큰 기능을 바로 구현하려고 하면 길을 잃기 쉽고, 나중에 디버깅에 많은 어려움을 겪을 수 있습니다. 개발자(접니다)로 하여금 소화할 수 있는 사이즈로 기능을 쪼개, 어떻게 구현할지 계획하고, 어디까지 완료됐는지 파악할 수 있는 체크포인트 역할을 하도록 합니다.
통합 환경과 독립된 테스트를 진행할 수 있도록, 핵심 비즈니스 로직을 포함하고 있는 Entity와 Service 레이어에 대한 단위 테스트를 작성합니다. 해당 계층의 테스트가 탄탄하면, 문제가 생겼을 때 이것이 비즈니스 로직 문제인지, 통합 환경의 문제인지 빠르게 파악할 수 있고, 이게 정말 많은 시간과 고통을 줄여줄 수 있기 때문에 진짜 진짜 중요합니다...!
하루죙일 삽질했는데 사실 환경 변수 문제였을 때의 기분을 아십니까...!
개인적으로 디버깅이란 멀쩡하다고 확신하는 부분을 늘려가는 과정이라고 생각합니다.
서버는 Spring Boot로 구현합니다. 데이터 접근 기술로 JPA(Hibernate)를 이용합니다. JPA를 사용하는 것이 도메인 중심의 아키텍처를 설계하고 테스트를 작성하는 데 유리할 것이라 판단했습니다.
데이터베이스로 MySQL을 사용합니다. 엔티티와 서비스 계층까지의 구현이 완료되고 나면 통합 테스트를 진행합니다.
SpringBoot 3.5.0
Java 21 (나중에 꼭 Virtual Thread 써보고 싶어서)
Spring Web
Spring Data JPA
MySQL Driver
Lombok
application.yml
spring:
datasource:
url: jdbc:mysql://localhost/ppalatjyo
username: [유저네임]
password: [비밀번호]
아직 특별히 해준 건 없습니다. 데이터소스 연동만 해줍니다. yml 파일은 gitignore 해줍니다.
TDD의 왕도는 컴파일 에러를 내는 걸로 시작합니다. 클래스도 생성 안 하고 시작하는 것이 책에는 예시로 나와있습니다. 그러나 그렇게까지 하기는 조금 귀찮으므로! 저는 빈 클래스를 작성하는 걸로 시작하겠습니다.
각 기능의 기준은 해당 기능이 영향을 주는 도메인(엔티티, 테이블)로 정합니다.
예) 유저를 생성 ->
user.create(String nickname)
예) 유저가 로비를 생성 ->lobby.create(User user)이유는 안 그럼 모든 행동의 주체가 되는 User가 슈퍼 클래스가 되어버리기 때문입니다.
유저가 할 수 있는 일은 다음과 같습니다.
그럼 바로 만들어보겠습니다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id @GeneratedValue
private Long id;
}
빈 껍데기 엔티티를 생성합니다. 바로 첫번째 요구사항부터 테스트를 써봅니다.

아무것도 없어서 난리가 났습니다. 이제 빠르게 구현을 해줍니다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id @GeneratedValue
private Long id;
private String nickname;
private UserRole role;
private LocalDateTime createdAt;
private LocalDateTime deletedAt;
private LocalDateTime lastAccessedAt;
public static User createGuest(String nickname) {
return new User();
}
}

컴파일 에러를 모두 해결했습니다. 하지만 createGuest()가 구현 안 됐으므로 테스트는 통과하지 않습니다. 이제 테스트 통과를 위한 최소 구현을 합니다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id @GeneratedValue
private Long id;
private String nickname;
private UserRole role;
private LocalDateTime createdAt;
private LocalDateTime deletedAt;
private LocalDateTime lastAccessedAt;
public static User createGuest(String nickname) {
User guest = new User();
guest.nickname = nickname;
guest.role = UserRole.GUEST;
guest.createdAt = LocalDateTime.now();
guest.lastAccessedAt = LocalDateTime.now();
return guest;
}
}
나도 모르게 본능적으로 리펙토링하는 나 자신을 마주했으나, 유혹을 뿌리치고 가능한 한 원시적인 코드를 작성했습니다. 원래부터 별 요소가 없는 코드이긴 합니다.

(이 초록불 보는 맛에 코딩하지)
이제 테스트까지 초록불이 뜹니다. 이제 리펙토링을 해줘야합니다. 아까 참았던 롬복의 @Builder를 넣어주고자 합니다. 또한 createdAt과 lastAccessedAt은 생성할 때 반드시 현재 시간이 들어가는 값이므로 @Builder.Default를 통해 디폴트값을 줄 수 있을 것 같습니다. @Builder에 필요한 전체 필드 생성자(@AllArgsConstructor)는 현재 외부에 공개할 생각이 없으므로 PRIVATE 엑세스로 설정해줍니다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class User {
@Id @GeneratedValue
private Long id;
private String nickname;
private UserRole role;
@Builder.Default
private LocalDateTime createdAt = LocalDateTime.now();
private LocalDateTime deletedAt;
@Builder.Default
private LocalDateTime lastAccessedAt = LocalDateTime.now();
public static User createGuest(String nickname) {
return User.builder()
.nickname(nickname)
.role(UserRole.GUEST)
.build();
}
}

리펙토링을 통해 코드 가독성과 안정성을 개선했습니다. 이전에 이미 테스트가 있었으므로, 멀쩡히 작동하는지 바로 확인해볼 수 있습니다.
이걸로 하나 해결했으니 할 일 목록에서 지워줍니다.
아무튼 이런 식으로 진행하는게 TDD 입니다. 밑에 거까지 다 이렇게 글로 쓰다간 평생 걸릴 것 같으니 나머지는 따로 설명없이 작성해보겠습니다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class User {
@Id
@GeneratedValue
private Long id;
private String nickname;
private String oAuthEmail;
private String oAuthProvider;
@Enumerated(value = EnumType.STRING)
private UserRole role;
@Builder.Default
private LocalDateTime createdAt = LocalDateTime.now();
@Builder.Default
private LocalDateTime lastModifiedAt = LocalDateTime.now();
private LocalDateTime deletedAt;
@Builder.Default
private LocalDateTime lastAccessedAt = LocalDateTime.now();
public static User createGuest(String nickname) {
return User.builder()
.nickname(nickname)
.role(UserRole.GUEST)
.build();
}
public static User createMember(String nickname, String oAuthEmail, String oAuthProvider) {
return User.builder()
.nickname(nickname)
.role(UserRole.MEMBER)
.oAuthEmail(oAuthEmail)
.oAuthProvider(oAuthProvider)
.build();
}
public void promoteGuestToMember(String oAuthEmail, String oAuthProvider) {
if (this.role == UserRole.MEMBER || this.role == UserRole.ADMIN) {
throw new UserAlreadyMemberException();
}
this.oAuthEmail = oAuthEmail;
this.oAuthProvider = oAuthProvider;
this.role = UserRole.MEMBER;
this.lastModifiedAt = LocalDateTime.now();
}
public void changeNickname(String nickname) {
if (nickname == null || nickname.trim().isEmpty()) {
throw new IllegalArgumentException("Nickname must not be null or empty");
}
this.nickname = nickname;
}
}
class UserTest {
@Test
@DisplayName("GUEST User 생성")
void createGuestUser() {
// given
String nickname = "nickname";
// when
User user = User.createGuest(nickname);
// then
assertThat(user.getNickname()).isEqualTo(nickname);
assertThat(user.getRole()).isEqualTo(UserRole.GUEST);
assertThat(user.getCreatedAt()).isNotNull();
assertThat(user.getLastModifiedAt()).isNotNull();
assertThat(user.getLastAccessedAt()).isNotNull();
assertThat(user.getDeletedAt()).isNull();
}
@Test
@DisplayName("MEMBER User 생성")
void createMemberUser() {
// given
String nickname = "nickname";
String oAuthEmail = "member@test.com";
String oAuthProvider = "provider";
// when
User user = User.createMember(nickname, oAuthEmail, oAuthProvider);
// then
assertThat(user.getNickname()).isEqualTo(nickname);
assertThat(user.getRole()).isEqualTo(UserRole.MEMBER);
assertThat(user.getCreatedAt()).isNotNull();
assertThat(user.getLastModifiedAt()).isNotNull();
assertThat(user.getLastAccessedAt()).isNotNull();
assertThat(user.getDeletedAt()).isNull();
assertThat(user.getOAuthEmail()).isEqualTo(oAuthEmail);
assertThat(user.getOAuthProvider()).isEqualTo(oAuthProvider);
}
@Test
@DisplayName("GUEST를 MEMBER로 승격")
void promoteGuestToMember() throws InterruptedException {
// given
String nickname = "nickname";
User user = User.createGuest(nickname);
String oAuthEmail = "member@test.com";
String oAuthProvider = "provider";
LocalDateTime lastModifiedAtBefore = user.getLastModifiedAt();
Thread.sleep(10); // updatedAtBefore 와 업데이트 된 시간이 밀리초까지 일치하여 추가
// when
user.promoteGuestToMember(oAuthEmail, oAuthProvider);
// then
assertThat(user.getRole()).isEqualTo(UserRole.MEMBER);
assertThat(user.getOAuthEmail()).isEqualTo(oAuthEmail);
assertThat(user.getOAuthProvider()).isEqualTo(oAuthProvider);
assertThat(user.getLastModifiedAt()).isAfter(lastModifiedAtBefore);
}
@Test
@DisplayName("이미 MEMBER인 경우 승격 시 예외 발생")
void promoteMemberToMemberThrowsException() {
// given
String nickname = "nickname";
String oAuthEmail = "member@test.com";
String oAuthProvider = "provider";
User user = User.createMember(nickname, oAuthEmail, oAuthProvider);
// when
// then
assertThatThrownBy(() -> user.promoteGuestToMember(oAuthEmail, oAuthProvider))
.isInstanceOf(UserAlreadyMemberException.class);
}
@Test
@DisplayName("유저는 nickname 변경 가능")
void editNickname() {
// given
String nickname = "nickname";
User user = User.createGuest(nickname);
String newNickname = "newNickname";
// when
user.changeNickname(newNickname);
// then
assertThat(user.getNickname()).isEqualTo(newNickname);
}
@Test
@DisplayName("nickname 변경 시 null 혹은 빈 값이면 예외 발생")
void editNicknameNullThrowsException() {
// given
String nickname = "nickname";
User user = User.createGuest(nickname);
// when
// then
assertThatThrownBy(() -> user.changeNickname(null))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> user.changeNickname(""))
.isInstanceOf(IllegalArgumentException.class);
}
}

쓰다보니 몇 가지 추가됐습니다. 아직 많이 부족하지만, 확실히 전보다 훨씬 든든한 도메인 클래스를 작성하게 된 것 같습니다. 이제 나머지 Lobby, UserLobby, Message, Game, UserGame, Quiz, Question, Answer만 구현하면 됩니다...! 이 친구들은 블로그 분량상 생략하겠습니다ㅎㅎㅎ.
그 이후에는 실제 영속성 레이어와 상호작용할 서비스 레이어를 비슷한 방식으로 구현해보겠습니다. 여기에는 간단히 UserService만 구현해보겠습니다.
Service 레이어는 다른 모든 레이어(Presentation, Persistence layer, Domain Model)를 연결해주는, 핵심 비즈니스 로직을 처리하는 레이어입니다. 앞서 작성한 Domain(Entity) 계층과의 다른 점은 도메인 계층은 해당 도메인, 그리고 연관된 도메인 사이 로직, 즉, 데이터 중심의 로직을 담고 있고, 서비스 레이어는 사용자의 요청부터 도메인, 영속까지의 각 하나의 비즈니스 로직 흐름을 담고 있다는 것입니다.
예를 들어 User 도메인의 역할은 해당 도메인이 어떻게 탄생하고, 변경되는지 등 도메인 자체의 로직을 담당하고 있지만, 해당 과정을 위해 어떻게 데이터를 받고, 어떤 데이터를 반환하고, 어떻게 영속될 것인지에 대해서는 관여하지 않습니다.
User는 nickname이 있으면 GUEST 역할의 새 유저를 생성해줄 수 있다는 사실을 알지만, 그 과정에 토큰을 생성해서 저장 및 반환하고, JPA를 통해 영속처리를 할거란 사실은 알지 못합니다. 이걸 알고 있는 친구가 서비스 레이어입니다.
UserRepository는JpaRepository를 직접 사용해주었습니다. 이 부분은 고민을 좀 많이 했습니다. JpaRepository를 사용하는 UserRepositoryImpl을 중간에 넣어서 DAO 계층과 Service를 계층을 완전히 분리할지, 아니면 바로 JpaRepository에 의존할지 사이에서 아직도 명확한 해답을 내리지 못했습니다.결국 내린 결론은 "일단 해보고 아니다 싶으면 바꾸자!"입니다. JpaRepository를 직접 의존하고, 테스트에는 Mockito를 활용하기로 했습니다.
UserService의 요구사항은 다음과 같습니다:
※ JWT 관련 로직은 현재 구현하지 않습니다.
※ 조회 관련 로직은 현재 구현하지 않습니다(화면 스펙을 아직 몰라서).
아래는 최종적으로 구현된 클래스와 테스트입니다.
@Service
@RequiredArgsConstructor
@Transactional
public class UserService {
private final UserRepository userRepository;
public JoinAsGuestResponseDto joinAsGuest(String nickname) {
User guest = User.createGuest(nickname);
userRepository.save(guest);
return new JoinAsGuestResponseDto("", "");
}
public JoinAsMemberResponseDto joinAsMember(String nickname, String oAuthEmail, String oAuthProvider) {
User member = User.createMember(nickname, oAuthEmail, oAuthProvider);
userRepository.save(member);
return new JoinAsMemberResponseDto("", "");
}
public void promoteGuestToMember(Long userId, String oAuthEmail, String oAuthProvider) {
User user = userRepository.findById(userId).orElseThrow();
user.promoteGuestToMember(oAuthEmail, oAuthProvider);
}
public void changeNickname(Long userId, String newNickname) {
User user = userRepository.findById(userId).orElseThrow();
user.changeNickname(newNickname);
}
}
@ExtendWith(MockitoExtension.class)
@Transactional
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
@DisplayName("GUEST로 참가시 토큰 반환")
void joinAsGuest() {
// given
String nickname = "nickname";
// when
JoinAsGuestResponseDto responseDto = userService.joinAsGuest(nickname);
// then
assertThat(responseDto.getAccessToken()).isNotNull();
assertThat(responseDto.getRefreshToken()).isNotNull();
// TODO: 2025-06-02 Token 검증 추가
}
@Test
@DisplayName("MEMBER로 가입")
void joinAsMember() {
// given
String nickname = "nickname";
String oAuthEmail = "test@test.com";
String oAuthProvider = "github";
// when
JoinAsMemberResponseDto responseDto = userService.joinAsMember(nickname, oAuthEmail, oAuthProvider);
// then
assertThat(responseDto.getAccessToken()).isNotNull();
assertThat(responseDto.getRefreshToken()).isNotNull();
// TODO: 2025-06-02 Token 검증 추가
}
@Test
@DisplayName("GUEST를 MEMBER로 승격")
void promoteGuestToMember() {
// given
User user = User.createGuest("nickname");
Long id = 1L;
String oAuthEmail = "test@test.com";
String oAuthProvider = "github";
when(userRepository.findById(id)).thenReturn(Optional.of(user));
// when
userService.promoteGuestToMember(id, oAuthEmail, oAuthProvider);
// then
assertThat(user.getRole()).isEqualTo(UserRole.MEMBER);
}
@Test
@DisplayName("User nickname 변경")
void changeNickname() {
// given
String oldNickname = "oldNickname";
String newNickname = "newNickname";
User user = User.createGuest(oldNickname);
Long id = 1L;
when(userRepository.findById(id)).thenReturn(Optional.of(user));
// when
userService.changeNickname(id, newNickname);
// then
assertThat(user.getNickname()).isEqualTo(newNickname);
}
}
토큰의 생성과 유효성 검사는 Spring Security의 내장 Filter에서 이루어질 예정입니다. 현재는 거의 모습만 정의해둔 상태입니다. 서버로부터 발급된 토큰을 가진 사용자는 로그인 상태를 유지합니다.
TDD를 처음 시도해보는 만큼, 재밌기도 하고 어색하기도 했습니다. 아 이렇게 하면 되겠다 싶은 부분도 있고, 이게 맞나? 싶은 부분도 많았던 것 같습니다.
테스트를 먼저 작성했기 때문에, 테스트가 없는 메서드는 존재하지 않습니다. 특히나 테스트에서 필요 없어진 메서드는 남겨두지 않으려고 했습니다. 모든 메서드를 "테스트 통과 먼저"라는 생각으로 썼기 때문에 어렵지 않았던 것 같습니다.
테스트가 없을 때의 단점으로 꼽은 건데, 테스트를 먼저 작성하니 현재 어디까지 구현을 했고, 앞으로 뭘 해야할지 명확하게 인지하고 계획할 수 있는 것 같습니다. 특히, 당장 구현이 필요한 기능인지 아닌지 판단에 도움을 줍니다. 목적은 현재 테스트의 통과이므로, 이에 영향을 주지 않는 구현은 과감히 생략할 수 있습니다. 필요해지면 또 테스트를 만들 것입니다.
저장과 업데이트 모두 영속 레이어에서 JPA에 의해 자동으로 이루어지고, 서비스는 이를 호출하는 것일 뿐이기 때문에 테스트 성공 여부를 어떻게 정해야할 지에 대해 고민이 많았습니다. 우선은 최대한 구현을 해봤지만 아직 이렇다할 느낌이 오지는 않는 것 같습니다. 앞으로 구현을 해가면서 계속 방향을 잡아야할 것 같습니다. 적어도, 뭔가 이상하면 어디로 돌아와야 할지 알고 있다는 사실은 위안이 됩니다.
우선 간단하게 TDD를 적용해 개발하는 과정과 느낀 점을 정리해보았습니다. 잘 쓴 테스트인가에 아직 자신은 없지만, 적어도 스스로 테스트를 계획하고 모두 통과시킨 점은 확실히 뿌듯합니다. 이제 나머지 부분 구현을 마치고 통합 테스트까지 해보고 나면, 클라이언트 개발로 넘어가겠습니다.
마지막으로 언제봐도 기분 좋은 전체 테스트 통과 스샷을 첨부하며 마치겠습니다ㅎㅎㅎ
