상품 공급 시스템 - 회원 관련 기능 구현

Member 클래스

회원 한 명을 표현하는 객체이며 데이터베이스의 member테이블에 대응된다.
Builder 패턴을 적용하였다.

public class Member {

    private final Long memberId;
    private final String email;
    private String password;
    private String nickname;
    private LocalDate lastPasswordUpdateDate;
    private final LocalDate lastSigninDate;
    private final LocalDate accountCreationDate;
    boolean enabled = true;
    int degree;

    public Long getMemberId() {
        return memberId;
    }

    public String getEmail() {
        return email;
    }

    public String getPassword() {
        return password;
    }

    public String getNickname() {
        return nickname;
    }

    public boolean isPasswordUpdateRequired() {
        LocalDate currentDate = LocalDate.now();
        return ChronoUnit.MONTHS
                .between(lastPasswordUpdateDate, currentDate)
                == MemberPolicy.PASSWORD_UPDATE_PERIOD_MONTH;
    }

    public boolean isDormantAccount() {
        LocalDate currentDate = LocalDate.now();
        return ChronoUnit.YEARS
                .between(lastSigninDate, currentDate)
                == MemberPolicy.DORMANT_ACCOUNT_TRANSITION_PERIOD_YEAR;
    }

    public LocalDate getAccountCreationDate() {
        return accountCreationDate;
    }

    public boolean isEnabled() {
        return enabled;
    }

    public int getDegree() { return degree; }

    public Member(Builder builder) {
        this.memberId = builder.memberId;
        this.email = builder.email;
        this.nickname = builder.nickname;
        this.lastPasswordUpdateDate = builder.lastPasswordUpdateDate;
        this.lastSigninDate = builder.lastSigninDate;
        this.accountCreationDate = builder.lastPasswordUpdateDate;
        this.enabled = builder.enabled;
        this.degree = builder.degree;
    }

    public static Builder builder() { return new Builder(); }
    public static Builder builder(Long memberId) { return new Builder(memberId); }

    public static class Builder {

        private Long memberId;
        private String email;
        private String password;
        private String nickname;
        private LocalDate lastPasswordUpdateDate;
        private LocalDate lastSigninDate;
        private LocalDate accountCreationDate;
        boolean enabled;
        int degree;

        public Builder(Long memberId) { this.memberId = memberId; }
        public Builder() { }

        public Builder email(String email) {
            this.email = email;
            return this;
        }

        public Builder password(String password) {
            this.password = password;
            return this;
        }

        public Builder nickname(String nickname) {
            this.nickname = nickname;
            return this;
        }

        public Builder lastPasswordUpdateDate(LocalDate lastPasswordUpdateDate){
            this.lastPasswordUpdateDate = lastPasswordUpdateDate;
            return this;
        }

        public Builder lastSigninDate(LocalDate lastSigninDate){
            this.lastSigninDate = lastSigninDate;
            return this;
        }

        public Builder accountCreationDate(LocalDate accountCreationDate) {
            this.accountCreationDate = accountCreationDate;
            return this;
        }

        public Builder enabled(boolean enabled) {
            this.enabled = enabled;
            return this;
        }

        public Builder degree(int degree) {
            this.degree = degree;
            return this;
        }

        public Member build() {
            return new Member(this);
        }

    }

}

닉네임 중복검사 기능

API SPEC
회원이 희망하는 닉네임이 사용 가능한지 확인하는 기능이다.

컨트롤러

@GetMapping("/nickname/{nickname}/exists")
public BaseResponse checkNicknameAvailability(@PathVariable("nickname") String nickname) {
    return new BaseResponse(signupService.checkNicknameAlreadyExists(nickname));
}

서비스

public ResponseStatus checkNicknameAlreadyExists(String nickname) {
    Optional<Member> member;
    try {
        member = memberDao.findByNickname(nickname);
    } catch (SQLException e) {
        return DATABASE_EXCEPTION;
    }
    if (member.isPresent())
        return NICKNAME_ALREADY_EXISTS;
    else
        return USABLE_NICKANME;
}

DAO

public interface MemberDao {

    public Optional<Member> findByNickname(String nickname) throws SQLException;
}

나중에 JDBC가 아닌 JDBCTemplate이나 JPA로 리팩토링할 경우를 대비해 서비스는 인터페이스에 의존하도록 구현했다.

@Configuration
public class DatabaseConfig {

    @Autowired
    DataSource ds;

    @Bean
    MemberDao memberDao() {
        return new MemberDaoJDBC(ds);
    }
}

DAO의 구현체는 위와 같은 설정으로 선택할 수 있다.

아래는 JDBC를 사용한 구현체의 메서드이다.

public Optional<Member> findByNickname(String nickname) throws SQLException {
    final String query = "SELECT member_id FROM member WHERE nickname = ?";
    try (Connection conn = dataSource.getConnection();
         PreparedStatement stmt = conn.prepareStatement(query);) {
        stmt.setString(1, nickname);
        try (ResultSet resultSet = stmt.executeQuery();) {
            Member member = null;
            if (resultSet.next()) {
                member = Member.builder(resultSet.getLong("member_id"))
                        .email(resultSet.getString("email"))
                        .password(resultSet.getString("password"))
                        .nickname(resultSet.getString("nickname"))
                        .lastPasswordUpdateDate(resultSet.getDate("lastPasswordUpdateDate").toLocalDate())
                        .accountCreationDate(resultSet.getDate("accountCreationDate").toLocalDate())
                        .enabled(resultSet.getBoolean("enabled"))
                        .degree(resultSet.getInt("degree"))
                        .build();
            }
            return Optional.ofNullable(member);
        } catch (SQLException e) {
            throw e;
        }
    } catch (SQLException e) {
        System.out.println(e.getMessage());
        throw e;
    }
}

try-with-resources 구문을 사용하여 Connection PreparedStatement ResultSet을 할당하고 해제한다.

close()를 호출해야 할까?

JDBC를 사용하여 DB에 접근하는 코드를 작성하다 보니 궁금해졌다. JVM에는 Garbage Collector가 있어서 메모리 관리를 해주는데 왜 close()를 호출해서 프로그래머가 직접 자원을 해제하는걸까.
그 이유는 JVM이 접근할 수 없는 대상이기 때문이다.
명시적으로 close() 함수를 호출하여 할당된 자원을 해제해야 하는 객체들은 운영 체제가 관리하는 객체들이다.
하나의 응용 프로그램에 불과한 JVM은 운영체제의 권한을 가질 수 없기 때문에 이 자원을 알아서 해제할 수 없다.
이런 자원에는 File, Socket, Database 관련 리소스 그리고 Thread Pool이 있다.

open file table

open file table은 모든 프로세스가 공유하며 entry의 개수에는 제한이 있다. 하나의 프로세스에서 똑같은 파일을 여러 번 open하면 그 만큼의 entry가 생성된다.
open file table의 entry는 reference count값이 0이 되면 해제된다.

프로세스가 할당받은 자원을 해제하지 않고 종료되면 운영체제가 해당 자원들을 모두 해제하긴 한다.

조회 결과가 없는 경우 null을 반환하는 것을 막기 위해 Optional로 감싸서 반환한다.

테스트

입력으로 받는 닉네임을 사용할 수 있는 경우와 사용할 수 없는 경우 각각에 대해 테스트 함수를 작성했다.
데이터베이스에 접근하는 DAO 클래스는 Mock으로 대체했다.

public class Signup {

    private final MemberDao memberDao = Mockito.mock(MemberDaoJDBC.class);
    private final MemberFactory memberFactory = new MemberFactoryImpl();
    private SignupService signupService;

    @BeforeEach
    public void setup() {
        this.signupService = new SignupService(memberDao);
    }

    @Test
    void 사용가능한_닉네임_중복검사() throws SQLException {
        // given
        Optional<Member> retVal = Optional.ofNullable(null);
        Mockito.when(memberDao.findByNickname(any(String.class)))
                .thenReturn(retVal);
        // when
        ResponseStatus status = signupService.checkNicknameAlreadyExists("foo");
        // then
        Assertions.assertThat(status).isEqualTo(USABLE_NICKANME);
    }

    @Test
    void 이미_사용중인_닉네임_중복검사() throws SQLException {
        // given
        Optional<Member> retVal = Optional.of(memberFactory.getDefaultSettingMember(0l));
        Mockito.when(memberDao.findByNickname(any(String.class)))
                .thenReturn(retVal);
        // when
        ResponseStatus status = signupService.checkNicknameAlreadyExists("foo");
        // then
        Assertions.assertThat(status).isEqualTo(NICKNAME_ALREADY_EXISTS);
    }
}

닉네임 중복검사 테스트

Comments