4편 - [Spring Boot] 피자 가게 - 회원 관리 (회원가입 / 로그인)

지현·어제
post-thumbnail

이 글에서 다룰 내용

  1. 프로젝트 구조 설명
  2. 회원가입 백엔드
    • DB 테이블 생성
    • MemberVO
    • DTO 생성 (Request / Response)
    • MemberMapper
    • MemberService
    • MemberController
  3. 회원가입 프론트
  4. 로그인 백엔드
    • MemberService
    • MemberController
  5. 로그인 프론트
  6. 공통 기능 (common.js)
  7. 동작 확인

1. 프로젝트 구조 설명

백엔드

pizza-shop/
├── src/main/java/
│   └── com.pizzashop/
│       ├── controller/
│       │   ├── MenuController.java
│       │   └── MemberController.java
│       ├── dto/
│       │   ├── request/
│       │   │   ├── MemberRegisterRequest.java
│       │   │   └── MemberLoginRequest.java
│       │   └── response/
│       │       ├── ApiResponse.java
│       │       ├── MemberLoginResponse.java
│       ├── mapper/
│       │   ├── MenuMapper.java
│       │   └── MemberMapper.java
│       ├── service/
│       │   ├── MenuService.java
│       │   └── MemberService.java
│       └── vo/
│           ├── MenuVO.java
│           └── MemberVO.java
패키지설명
controller클라이언트 요청을 받아 응답을 반환
dto/request클라이언트에서 받는 요청 데이터
dto/response클라이언트에게 보내는 응답 데이터
mapperMyBatis를 통해 DB와 직접 통신
service비즈니스 로직 처리 (중복 확인, 비밀번호 검증 등)
voDB 테이블과 1:1 매핑되는 데이터 객체

Controller → Service → Mapper → DB 순서로 요청이 흘러간다.

프론트엔드

PIZZA-SHOP/
├── css/
│   ├── style.css
│   └── auth.css
├── js/
│   ├── common.js
│   ├── menu.js
│   └── auth.js
├── index.html
├── login.html
└── register.html
파일설명
auth.css로그인 / 회원가입 페이지 전용 스타일
common.js공통 기능 (updateNav, logout)
auth.js로그인 / 회원가입 API 연동
login.html로그인 페이지
register.html회원가입 페이지

2. 회원가입 백엔드

요청 흐름은 다음과 같다.

클라이언트 → Controller → Service → Mapper → DB

DB 테이블 생성

회원 정보를 저장할 members 테이블을 먼저 생성한다.

CREATE TABLE IF NOT EXISTS members (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    password VARCHAR(100) NOT NULL,
    name VARCHAR(50) NOT NULL,
    role VARCHAR(20) DEFAULT 'USER',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
컬럼타입설명
idINT고유번호 (자동 증가)
usernameVARCHAR(50)로그인 아이디 (중복 불가)
passwordVARCHAR(100)비밀번호 (실무에서는 암호화 필요)
nameVARCHAR(50)닉네임
roleVARCHAR(20)권한 (USER / ADMIN, 기본값 USER)
created_atTIMESTAMP가입일 (자동 저장)

MemberVO

DB 테이블과 1:1로 매핑되는 데이터 객체다.

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemberVO {
    private int id;           // 고유번호
    private String username;  // 로그인 아이디
    private String password;  // 비밀번호 (실무에서는 암호화 필요)
    private String name;      // 닉네임
    private String role;      // 권한 (USER / ADMIN)
    private String createdAt; // 가입일
}
어노테이션설명
@Datagetter, setter, toString 자동 생성
@NoArgsConstructor기본 생성자 자동 생성
@AllArgsConstructor전체 필드 생성자 자동 생성

DTO 생성

클라이언트와 주고받는 데이터를 명확하게 분리하기 위해 DTO를 사용한다.

VO를 그대로 사용하면 클라이언트가 role 같은 민감한 필드를 임의로 넘길 수 있어 보안에 취약하다. DTO를 사용하면 필요한 필드만 주고받을 수 있다.

MemberRegisterRequest - 회원가입 요청 데이터

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemberRegisterRequest {
    private String username;
    private String password;
    private String name;
}

MemberLoginRequest - 로그인 요청 데이터

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemberLoginRequest {
    private String username;
    private String password;
}

ApiResponse - 공통 응답 데이터

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse {
    private String result;  // "ok" 또는 "fail"
    private String message; // 성공 또는 실패 메시지
}

MemberLoginResponse - 로그인 응답 데이터

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemberLoginResponse {
    private String result;   // 실행 결과
    private String name;     // 사용자 이름
    private String role;     // 사용자 역할 (USER / ADMIN)
    private String username; // 사용자 아이디
}

로그인 응답에 password는 절대 포함하면 안 된다.


MemberMapper

@Mapper
public interface MemberMapper {

    // 아이디 중복 체크 / 로그인 시 회원 조회
    @Select("SELECT * FROM members WHERE username = #{username}")
    MemberVO findByUsername(String username);

    // 회원가입 INSERT
    // id, role, created_at은 DB에서 자동 생성되므로 쿼리에서 제외
    @Insert("INSERT INTO members(username, password, name) "
            + "VALUES (#{username}, #{password}, #{name})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    int insert(MemberVO member);
}
메서드설명
findByUsername아이디로 회원 조회 (중복 체크 / 로그인에 사용)
insert회원 정보 DB에 저장

@Options(useGeneratedKeys = true, keyProperty = "id") 는 INSERT 후 자동 생성된 id 값을 MemberVO.id에 다시 넣어준다.


MemberService - 회원가입

@Service
public class MemberService {

    @Autowired
    private MemberMapper memberMapper;

    // 1. 같은 아이디가 DB에 존재하는지 조회
    // 2. 있으면 false 반환 -> Controller에서 "중복 아이디" 응답
    // 3. 없으면 INSERT 실행 -> 성공 시 true 반환
    public boolean register(MemberRegisterRequest request) {
        MemberVO existing = memberMapper.findByUsername(request.getUsername());
        if (existing != null) {
            return false; // 중복 아이디 -> 가입 거절
        }

        // Request -> VO 변환
        MemberVO member = new MemberVO();
        member.setUsername(request.getUsername());
        member.setPassword(request.getPassword());
        member.setName(request.getName());

        return memberMapper.insert(member) > 0;
    }
}

rolecreated_at은 DB에서 자동으로 설정되므로 따로 넣지 않아도 된다.


MemberController - 회원가입

@RestController
@RequestMapping("/api/member")
@CrossOrigin(origins = "*")
public class MemberController {

    @Autowired
    private MemberService memberService;

    // POST /api/member/register
    // 요청 Body : {"username": "hong", "password": "1234", "name": "홍길동"}
    // 응답 : {"result": "ok", "message": "회원가입 완료"}
    @PostMapping("/register")
    public ApiResponse register(@RequestBody MemberRegisterRequest request) {
        boolean ok = memberService.register(request);
        if (ok) {
            return new ApiResponse("ok", "회원가입 완료");
        } else {
            return new ApiResponse("fail", "이미 사용 중인 아이디입니다.");
        }
    }
}
어노테이션설명
@RestControllerController + ResponseBody, JSON 자동 반환
@RequestMapping공통 URL prefix /api/member 설정
@CrossOrigin프론트에서 API 호출 허용 (CORS 설정)
@PostMappingPOST 요청 처리
@RequestBodyJSON 요청 Body를 DTO로 변환

3. 회원가입 프론트

register.html

<div class="auth-container">
    <div class="auth-box">
        <h2>회원가입</h2>
        <div class="form-group">
            <label>아이디</label>
            <input type="text" id="regId" placeholder="아이디를 입력하세요" />
        </div>
        <div class="form-group">
            <label>비밀번호</label>
            <input type="password" id="regPw" placeholder="비밀번호를 입력하세요" />
        </div>
        <div class="form-group">
            <label>이름</label>
            <input type="text" id="regName" placeholder="이름을 입력하세요" />
        </div>
        <button class="btn-submit" onclick="register()">회원가입</button>
        <p class="auth-link">
            이미 계정이 있으신가요? <a href="login.html">로그인</a>
        </p>
    </div>
</div>

auth.js - 회원가입

const AUTH_API = "http://localhost:8080/api/member";

function register() {
    const id = document.getElementById("regId").value.trim();
    const pw = document.getElementById("regPw").value.trim();
    const name = document.getElementById("regName").value.trim();

    if (!id || !pw || !name) {
        alert("모든 항목을 입력해 주세요.");
        return;
    }

    fetch(`${AUTH_API}/register`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ username: id, password: pw, name: name }),
    })
        .then((res) => res.json())
        .then((data) => {
            if (data.result === "ok") {
                alert("가입 완료! 로그인 페이지로 이동합니다.");
                setTimeout(() => {
                    location.href = "login.html";
                }, 1500);
            } else {
                alert("가입 실패 : 아이디를 확인해 주세요.");
            }
        })
        .catch(() => alert("서버 오류"));
}
단계설명
입력값 검증빈 값이면 alert 후 종료
fetch POST 요청username, password, name을 JSON으로 전송
응답 처리result === "ok" 면 로그인 페이지로 이동

4. 로그인 백엔드

MemberService - 로그인

// 1. 아이디로 회원 조회
// 2. 비밀번호 일치하면 MemberVO 반환 (로그인 성공)
// 3. 아이디가 없거나 비밀번호 틀리면 null 반환 (로그인 실패)
public MemberVO login(String username, String password) {
    MemberVO member = memberMapper.findByUsername(username);
    if (member != null && member.getPassword().equals(password)) {
        return member;
    }
    return null;
}

MemberController - 로그인

// POST /api/member/login
// 요청 Body : {"username": "hong", "password": "1234"}
// 응답(성공) : {"result": "ok", "name": "홍길동", "role": "USER", "username": "hong"}
// 응답(실패) : {"result": "fail", "name": null, "role": null, "username": null}
@PostMapping("/login")
public MemberLoginResponse login(@RequestBody MemberLoginRequest request) {
    MemberVO member = memberService.login(request.getUsername(), request.getPassword());
    if (member != null) {
        return new MemberLoginResponse("ok", member.getName(), member.getRole(), member.getUsername());
    } else {
        return new MemberLoginResponse("fail", null, null, null);
    }
}

5. 로그인 프론트

login.html

<div class="auth-container">
    <div class="auth-box">
        <h2>로그인</h2>
        <div class="form-group">
            <label>아이디</label>
            <input type="text" id="loginId" placeholder="아이디를 입력하세요" />
        </div>
        <div class="form-group">
            <label>비밀번호</label>
            <input type="password" id="loginPw" placeholder="비밀번호를 입력하세요" />
        </div>
        <button class="btn-submit" onclick="login()">로그인</button>
        <p class="auth-link">
            계정이 없으신가요? <a href="register.html">회원가입</a>
        </p>
    </div>
</div>

auth.js - 로그인

function login() {
    const id = document.getElementById("loginId").value.trim();
    const pw = document.getElementById("loginPw").value.trim();

    if (!id || !pw) {
        alert("아이디와 비밀번호를 입력해 주세요.");
        return;
    }

    fetch(`${AUTH_API}/login`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ username: id, password: pw }),
    })
        .then((res) => res.json())
        .then((data) => {
            if (data.result === "ok") {
                localStorage.setItem("loginUser", data.name);
                localStorage.setItem("loginRole", data.role);
                localStorage.setItem("loginUserId", data.username);
                alert(`${data.name}님 환영합니다😊`);
                setTimeout(() => {
                    location.href = "index.html";
                }, 1500);
            } else {
                alert("아이디 또는 비밀번호가 틀렸습니다.");
            }
        })
        .catch(() => alert("서버 오류"));
}
단계설명
입력값 검증빈 값이면 alert 후 종료
fetch POST 요청username, password를 JSON으로 전송
로그인 성공name, role, username을 localStorage에 저장 후 메인으로 이동
로그인 실패오류 메시지 alert

로그인 성공 시 localStorage에 사용자 정보를 저장해두면 다른 페이지에서도 로그인 상태를 유지할 수 있다.


6. 공통 기능 (common.js)

// nav 업데이트
function updateNav() {
    const isLoggedIn = localStorage.getItem("loginUser");
    const loginRole = localStorage.getItem("loginRole");
    const navArea = document.getElementById("navArea");

    if (isLoggedIn) {
        const adminMenu = loginRole === "ADMIN" ? `<a href="admin.html">메뉴 관리</a>` : "";
        const cartMenu = loginRole === "USER" ? `<a href="cart.html">장바구니</a>` : "";

        navArea.innerHTML = `
            <a href="index.html">메뉴</a>
            ${adminMenu}
            ${cartMenu}
            <a href="#">로그아웃</a>
        `;
    }
}

// 로그아웃
function logout() {
    localStorage.clear();
    location.href = "login.html";
}

로그인 상태면 역할에 따라 nav가 동적으로 바뀐다. ADMIN이면 메뉴 관리, USER면 장바구니가 추가된다.


7. 동작 확인

📸 [스크린샷 - 회원가입 화면]
아이디, 비밀번호, 이름을 입력하고 회원가입 버튼을 클릭하면 가입 완료 알림이 뜨고 자동으로 로그인 페이지로 이동한다.

📸 [스크린샷 - 로그인 화면]
아이디와 비밀번호를 입력하고 로그인 버튼을 클릭하면 환영 메시지가 뜨고 메인 페이지로 이동한다.

📸 [스크린샷 - 로그인 성공 후 nav 변경된 화면]
로그인 성공 시 localStorage에 사용자 정보가 저장되고, nav가 역할에 따라 동적으로 변경된다. USER면 장바구니, ADMIN이면 메뉴 관리 링크가 추가된다.


마치며

이번 글에서는 회원가입과 로그인 기능을 백엔드와 프론트엔드로 나눠서 구현해봤다.

  • DTO로 요청/응답 데이터를 명확하게 분리
  • MyBatis로 DB 연동
  • localStorage로 로그인 상태 유지

다음 편에서는 관리자 페이지에서 메뉴 CRUD 기능을 구현할 예정이다.

0개의 댓글